diff --git a/.eslintrc.json b/.eslintrc.json index afb3db707..502b77153 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,52 +1,53 @@ { "root": true, "env": { "es6": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:flowtype/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended", "prettier/react", "prettier/flowtype" ], "parser": "babel-eslint", "plugins": ["react", "react-hooks", "flowtype", "monorepo", "import"], "rules": { "curly": "error", "linebreak-style": "error", "semi": "error", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "monorepo/no-relative-import": "error", "no-empty": ["error", { "allowEmptyCatch": true }], "import/no-unresolved": 0, "no-unused-vars": ["error", { "ignoreRestSiblings": true }], "react/prop-types": ["error", { "skipUndeclared": true }], "no-shadow": 1, "import/order": [ "warn", { "newlines-between": "always", "alphabetize": { "order": "asc", "caseInsensitive": true }, "groups": [ ["builtin", "external"], "internal" ] } - ] + ], + "prefer-const": "error" }, "settings": { "react": { "version": "detect" }, "import/ignore": ["react-native"], "import/internal-regex": "^(lib|native|server|web)/" } } diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 30a8ee358..796754500 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,311 +1,311 @@ // @flow import threadWatcher from '../shared/thread-watcher'; import type { ChangeUserSettingsResult, LogOutResult, LogInInfo, LogInResult, RegisterResult, UpdatePasswordInfo, RegisterInfo, AccessRequest, } from '../types/account-types'; import type { UserSearchResult } from '../types/search-types'; import type { PreRequestUserState } from '../types/session-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; import type { UserInfo, AccountUpdate } from '../types/user-types'; import type { HandleVerificationCodeResult } from '../types/verify-types'; import { getConfig } from '../utils/config'; import type { FetchJSON } from '../utils/fetch-json'; import sleep from '../utils/sleep'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const logOut = (fetchJSON: FetchJSON) => async ( preRequestUserState: PreRequestUserState, ): Promise => { let response = null; try { response = await Promise.race([ fetchJSON('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; }; const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); const deleteAccount = (fetchJSON: FetchJSON) => async ( password: string, preRequestUserState: PreRequestUserState, ): Promise => { const response = await fetchJSON('delete_account', { password }); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; }; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); const register = (fetchJSON: FetchJSON) => async ( registerInfo: RegisterInfo, ): Promise => { const response = await fetchJSON('create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }); return { currentUserInfo: { id: response.id, username: registerInfo.username, email: registerInfo.email, emailVerified: false, }, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; - for (let userInfoArray of userInfoArrays) { - for (let userInfo of userInfoArray) { + for (const userInfoArray of userInfoArrays) { + for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; - for (let id in merged) { + for (const id in merged) { flattened.push(merged[id]); } return flattened; } const cookieInvalidationResolutionAttempt = 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT'; const appStartNativeCredentialsAutoLogIn = 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN'; const appStartReduxLoggedInButInvalidCookie = 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE'; const socketAuthErrorResolutionAttempt = 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT'; const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); const logInFetchJSONOptions = { timeout: 20000 }; const logIn = (fetchJSON: FetchJSON) => async ( logInInfo: LogInInfo, ): Promise => { const watchedIDs = threadWatcher.getWatchedIDs(); const { source, ...restLogInInfo } = logInInfo; const response = await fetchJSON( 'log_in', { ...restLogInInfo, watchedIDs, platformDetails: getConfig().platformDetails, }, logInFetchJSONOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, source, }; }; const resetPasswordActionTypes = Object.freeze({ started: 'RESET_PASSWORD_STARTED', success: 'RESET_PASSWORD_SUCCESS', failed: 'RESET_PASSWORD_FAILED', }); const resetPassword = (fetchJSON: FetchJSON) => async ( updatePasswordInfo: UpdatePasswordInfo, ): Promise => { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await fetchJSON( 'update_password', { ...updatePasswordInfo, watchedIDs, platformDetails: getConfig().platformDetails, }, logInFetchJSONOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: updatePasswordInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, }; }; const forgotPasswordActionTypes = Object.freeze({ started: 'FORGOT_PASSWORD_STARTED', success: 'FORGOT_PASSWORD_SUCCESS', failed: 'FORGOT_PASSWORD_FAILED', }); const forgotPassword = (fetchJSON: FetchJSON) => async ( usernameOrEmail: string, ): Promise => { await fetchJSON('send_password_reset_email', { usernameOrEmail }); }; const changeUserSettingsActionTypes = Object.freeze({ started: 'CHANGE_USER_SETTINGS_STARTED', success: 'CHANGE_USER_SETTINGS_SUCCESS', failed: 'CHANGE_USER_SETTINGS_FAILED', }); const changeUserSettings = (fetchJSON: FetchJSON) => async ( accountUpdate: AccountUpdate, ): Promise => { await fetchJSON('update_account', accountUpdate); return { email: accountUpdate.updatedFields.email }; }; const resendVerificationEmailActionTypes = Object.freeze({ started: 'RESEND_VERIFICATION_EMAIL_STARTED', success: 'RESEND_VERIFICATION_EMAIL_SUCCESS', failed: 'RESEND_VERIFICATION_EMAIL_FAILED', }); const resendVerificationEmail = ( fetchJSON: FetchJSON, ) => async (): Promise => { await fetchJSON('send_verification_email', {}); }; const handleVerificationCodeActionTypes = Object.freeze({ started: 'HANDLE_VERIFICATION_CODE_STARTED', success: 'HANDLE_VERIFICATION_CODE_SUCCESS', failed: 'HANDLE_VERIFICATION_CODE_FAILED', }); const handleVerificationCode = (fetchJSON: FetchJSON) => async ( code: string, ): Promise => { const result = await fetchJSON('verify_code', { code }); const { verifyField, resetPasswordUsername } = result; return { verifyField, resetPasswordUsername }; }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = (fetchJSON: FetchJSON) => async ( usernamePrefix: string, ): Promise => { const response = await fetchJSON('search_users', { prefix: usernamePrefix }); return { userInfos: response.userInfos, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = (fetchJSON: FetchJSON) => async ( subscriptionUpdate: SubscriptionUpdateRequest, ): Promise => { const response = await fetchJSON( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; }; const requestAccessActionTypes = Object.freeze({ started: 'REQUEST_ACCESS_STARTED', success: 'REQUEST_ACCESS_SUCCESS', failed: 'REQUEST_ACCESS_FAILED', }); const requestAccess = (fetchJSON: FetchJSON) => async ( accessRequest: AccessRequest, ): Promise => { await fetchJSON('request_access', accessRequest); }; export { logOutActionTypes, logOut, deleteAccountActionTypes, deleteAccount, registerActionTypes, register, cookieInvalidationResolutionAttempt, appStartNativeCredentialsAutoLogIn, appStartReduxLoggedInButInvalidCookie, socketAuthErrorResolutionAttempt, logInActionTypes, logIn, resetPasswordActionTypes, resetPassword, forgotPasswordActionTypes, forgotPassword, changeUserSettingsActionTypes, changeUserSettings, resendVerificationEmailActionTypes, resendVerificationEmail, handleVerificationCodeActionTypes, handleVerificationCode, searchUsersActionTypes, searchUsers, updateSubscriptionActionTypes, updateSubscription, requestAccessActionTypes, requestAccess, }; diff --git a/lib/media/file-utils.js b/lib/media/file-utils.js index e36827928..f6cda3088 100644 --- a/lib/media/file-utils.js +++ b/lib/media/file-utils.js @@ -1,218 +1,218 @@ // @flow import fileType from 'file-type'; import invariant from 'invariant'; import type { Shape } from '../types/core'; import type { MediaType } from '../types/media-types'; type ResultMIME = 'image/png' | 'image/jpeg'; type MediaConfig = {| +mediaType: 'photo' | 'video' | 'photo_or_video', +extension: string, +serverCanHandle: boolean, +serverTranscodesImage: boolean, +imageConfig?: Shape<{| +convertTo: ResultMIME, |}>, +videoConfig?: Shape<{| +loop: boolean, |}>, |}; const mediaConfig: { [mime: string]: MediaConfig } = Object.freeze({ 'image/png': { mediaType: 'photo', extension: 'png', serverCanHandle: true, serverTranscodesImage: true, }, 'image/jpeg': { mediaType: 'photo', extension: 'jpg', serverCanHandle: true, serverTranscodesImage: true, }, 'image/gif': { mediaType: 'photo_or_video', extension: 'gif', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, videoConfig: { loop: true, }, }, 'image/heic': { mediaType: 'photo', extension: 'heic', serverCanHandle: false, serverTranscodesImage: false, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/webp': { mediaType: 'photo', extension: 'webp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/tiff': { mediaType: 'photo', extension: 'tiff', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/svg+xml': { mediaType: 'photo', extension: 'svg', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'image/bmp': { mediaType: 'photo', extension: 'bmp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'video/mp4': { mediaType: 'video', extension: 'mp4', // serverCanHandle set to false pending future video message progress serverCanHandle: false, serverTranscodesImage: false, }, 'video/quicktime': { mediaType: 'video', extension: 'mp4', // serverCanHandle set to false pending future video message progress serverCanHandle: false, serverTranscodesImage: false, }, }); const serverTranscodableTypes: Set<$Keys> = new Set(); const serverCanHandleTypes: Set<$Keys> = new Set(); -for (let mime in mediaConfig) { +for (const mime in mediaConfig) { if (mediaConfig[mime].serverTranscodesImage) { serverTranscodableTypes.add(mime); } if (mediaConfig[mime].serverCanHandle) { serverCanHandleTypes.add(mime); } } function getTargetMIME(inputMIME: string): ResultMIME { const config = mediaConfig[inputMIME]; if (!config) { return 'image/jpeg'; } const targetMIME = config.imageConfig && config.imageConfig.convertTo; if (targetMIME) { return targetMIME; } invariant( inputMIME === 'image/png' || inputMIME === 'image/jpeg', 'all images must be converted to jpeg or png', ); return inputMIME; } const bytesNeededForFileTypeCheck = 64; export type FileDataInfo = {| mime: ?string, mediaType: ?MediaType, |}; function fileInfoFromData( data: Uint8Array | Buffer | ArrayBuffer, ): FileDataInfo { const fileTypeResult = fileType(data); if (!fileTypeResult) { return { mime: null, mediaType: null }; } const { mime } = fileTypeResult; const rawMediaType = mediaConfig[mime] && mediaConfig[mime].mediaType; const mediaType = rawMediaType === 'photo_or_video' ? 'photo' : rawMediaType; return { mime, mediaType }; } function replaceExtension(filename: string, ext: string): string { const lastIndex = filename.lastIndexOf('.'); let name = lastIndex >= 0 ? filename.substring(0, lastIndex) : filename; if (!name) { name = Math.random().toString(36).slice(-5); } const maxReadableLength = 255 - ext.length - 1; return `${name.substring(0, maxReadableLength)}.${ext}`; } function readableFilename(filename: string, mime: string): ?string { const ext = mediaConfig[mime] && mediaConfig[mime].extension; if (!ext) { return null; } return replaceExtension(filename, ext); } const extRegex = /\.([0-9a-z]+)$/i; function extensionFromFilename(filename: string): ?string { const matches = filename.match(extRegex); if (!matches) { return null; } const match = matches[1]; if (!match) { return null; } return match.toLowerCase(); } const pathRegex = /^file:\/\/(.*)$/; function pathFromURI(uri: string): ?string { const matches = uri.match(pathRegex); if (!matches) { return null; } return matches[1] ? matches[1] : null; } const filenameRegex = /[^/]+$/; function filenameFromPathOrURI(pathOrURI: string): ?string { const matches = pathOrURI.match(filenameRegex); if (!matches) { return null; } return matches[0] ? matches[0] : null; } export { mediaConfig, serverTranscodableTypes, serverCanHandleTypes, getTargetMIME, bytesNeededForFileTypeCheck, fileInfoFromData, replaceExtension, readableFilename, extensionFromFilename, pathFromURI, filenameFromPathOrURI, }; diff --git a/lib/media/media-utils.js b/lib/media/media-utils.js index e7c91fe5a..fe1bc589b 100644 --- a/lib/media/media-utils.js +++ b/lib/media/media-utils.js @@ -1,58 +1,58 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import type { MultimediaMessageInfo, RawMultimediaMessageInfo, } from '../types/message-types'; const maxDimensions = Object.freeze({ width: 1920, height: 1920 }); const localhostRegex = /^http:\/\/localhost/; function shimUploadURI(uri: string, platformDetails: ?PlatformDetails) { if (!platformDetails || platformDetails.platform !== 'android') { return uri; } // We do this for testing in the Android emulator return uri.replace(localhostRegex, 'http://10.0.2.2'); } function contentStringForMediaArray(media: $ReadOnlyArray): string { invariant(media.length > 0, 'there should be some media'); if (media.length === 1) { return `a ${media[0].type}`; } let firstType; - for (let single of media) { + for (const single of media) { if (!firstType) { firstType = single.type; } if (firstType === single.type) { continue; } else { return 'some media'; } } invariant(firstType, 'there should be some media'); if (firstType === 'photo') { firstType = 'image'; } return `some ${firstType}s`; } function multimediaMessagePreview( messageInfo: MultimediaMessageInfo | RawMultimediaMessageInfo, ): string { const mediaContentString = contentStringForMediaArray(messageInfo.media); return `sent ${mediaContentString}`; } export { maxDimensions, shimUploadURI, contentStringForMediaArray, multimediaMessagePreview, }; diff --git a/lib/permissions/thread-permissions.js b/lib/permissions/thread-permissions.js index 0e6fcdd29..cc8f99d77 100644 --- a/lib/permissions/thread-permissions.js +++ b/lib/permissions/thread-permissions.js @@ -1,129 +1,129 @@ // @flow import { type ThreadPermissionsBlob, type ThreadType, type ThreadPermission, type ThreadRolePermissionsBlob, type ThreadPermissionsInfo, threadTypes, threadPermissions, threadPermissionPrefixes, assertThreadPermissions, } from '../types/thread-types'; function permissionLookup( permissions: ?ThreadPermissionsBlob | ?ThreadPermissionsInfo, permission: ThreadPermission, ): boolean { if (!permissions || !permissions[permission]) { return false; } return permissions[permission].value; } function getAllThreadPermissions( permissions: ?ThreadPermissionsBlob, threadID: string, ): ThreadPermissionsInfo { const result = {}; - for (let permissionName in threadPermissions) { + for (const permissionName in threadPermissions) { const permissionKey = threadPermissions[permissionName]; const permission = permissionLookup(permissions, permissionKey); let source = null; if (permission) { if (permissions && permissions[permissionKey]) { source = permissions[permissionKey].source; } else { source = threadID; } } result[permissionKey] = { value: permission, source }; } return result; } // - rolePermissions can be null if role <= 0, ie. not a member // - permissionsFromParent can be null if there are no permissions from the // parent // - return can be null if no permissions exist function makePermissionsBlob( rolePermissions: ?ThreadRolePermissionsBlob, permissionsFromParent: ?ThreadPermissionsBlob, threadID: string, threadType: ThreadType, ): ?ThreadPermissionsBlob { const permissions = {}; if (permissionsFromParent) { - for (let permissionKey: any in permissionsFromParent) { + for (const permissionKey: any in permissionsFromParent) { const permissionValue = permissionsFromParent[permissionKey]; if ( threadType === threadTypes.CHAT_SECRET && (permissionKey.startsWith(threadPermissionPrefixes.OPEN_DESCENDANT) || permissionKey.startsWith(threadPermissionPrefixes.OPEN)) ) { continue; } if (permissionKey.startsWith(threadPermissionPrefixes.OPEN)) { const strippedPermissionKey = assertThreadPermissions( permissionKey.substr(5), ); permissions[strippedPermissionKey] = permissionValue; continue; } permissions[permissionKey] = permissionValue; } } if (rolePermissions) { - for (let permissionKey in rolePermissions) { + for (const permissionKey in rolePermissions) { const permissionValue = rolePermissions[permissionKey]; const currentValue = permissions[permissionKey]; if ( permissionValue || (!permissionValue && (!currentValue || !currentValue.value)) ) { permissions[permissionKey] = { value: permissionValue, source: threadID, }; } } } if (Object.keys(permissions).length === 0) { return null; } return permissions; } function makePermissionsForChildrenBlob( permissions: ?ThreadPermissionsBlob, ): ?ThreadPermissionsBlob { if (!permissions) { return null; } const permissionsForChildren = {}; - for (let permissionKey in permissions) { + for (const permissionKey in permissions) { const permissionValue = permissions[permissionKey]; if (permissionKey.startsWith(threadPermissionPrefixes.DESCENDANT)) { permissionsForChildren[permissionKey] = permissionValue; permissionsForChildren[permissionKey.substr(11)] = permissionValue; } else if (permissionKey.startsWith(threadPermissionPrefixes.CHILD)) { permissionsForChildren[permissionKey.substr(6)] = permissionValue; } } if (Object.keys(permissionsForChildren).length === 0) { return null; } return permissionsForChildren; } export { permissionLookup, getAllThreadPermissions, makePermissionsBlob, makePermissionsForChildrenBlob, }; diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index 232baf1ee..2d25addae 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,169 +1,169 @@ // @flow import { newThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, deleteThreadActionTypes, } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { filteredThreadIDs, nonThreadCalendarFilters, nonExcludeDeletedCalendarFilters, } from '../selectors/calendar-filter-selectors'; import { threadInFilterList } from '../shared/thread-utils'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types'; import type { BaseAction } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import type { RawThreadInfo } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { setNewSessionActionType } from '../utils/action-utils'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, ): $ReadOnlyArray { if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return defaultCalendarFilters; } else if (action.type === updateCalendarThreadFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: action.payload.threadIDs, }, ]; } else if (action.type === clearCalendarThreadFilter) { return nonThreadCalendarFilters(state); } else if (action.type === setCalendarDeletedFilter) { const otherFilters = nonExcludeDeletedCalendarFilters(state); if (action.payload.includeDeleted && otherFilters.length === state.length) { // Attempting to remove NOT_DELETED filter, but it doesn't exist return state; } else if (action.payload.includeDeleted) { // Removing NOT_DELETED filter return otherFilters; } else if (otherFilters.length < state.length) { // Attempting to add NOT_DELETED filter, but it already exists return state; } else { // Adding NOT_DELETED filter return [...state, { type: calendarThreadFilterTypes.NOT_DELETED }]; } } else if ( action.type === newThreadActionTypes.success || action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === processUpdatesActionType ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === incrementalStateSyncActionType) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } let changeOccurred = false; - for (let update of updateInfos) { + for (const update of updateInfos) { if (update.type === updateTypes.DELETE_THREAD) { const result = currentlyFilteredIDs.delete(update.threadID); if (result) { changeOccurred = true; } } else if (update.type === updateTypes.JOIN_THREAD) { if ( !threadInFilterList(update.threadInfo) || currentlyFilteredIDs.has(update.threadInfo.id) ) { continue; } currentlyFilteredIDs.add(update.threadInfo.id); changeOccurred = true; } else if (update.type === updateTypes.UPDATE_THREAD) { if (threadInFilterList(update.threadInfo)) { continue; } const result = currentlyFilteredIDs.delete(update.threadInfo.id); if (result) { changeOccurred = true; } } } if (changeOccurred) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...currentlyFilteredIDs] }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: { [id: string]: RawThreadInfo }, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter((threadID) => threadInFilterList(threadInfos[threadID]), ); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } diff --git a/lib/reducers/connection-reducer.js b/lib/reducers/connection-reducer.js index 7a28f45e0..a92ffab63 100644 --- a/lib/reducers/connection-reducer.js +++ b/lib/reducers/connection-reducer.js @@ -1,119 +1,119 @@ // @flow import { updateActivityActionTypes } from '../actions/activity-actions'; import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { queueActivityUpdatesActionType } from '../types/activity-types'; import { defaultCalendarQuery } from '../types/entry-types'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { type ConnectionInfo, updateConnectionStatusActionType, fullStateSyncActionType, incrementalStateSyncActionType, setLateResponseActionType, updateDisconnectedBarActionType, } from '../types/socket-types'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { unsupervisedBackgroundActionType } from './lifecycle-state-reducer'; export default function reduceConnectionInfo( state: ConnectionInfo, action: BaseAction, ): ConnectionInfo { if (action.type === updateConnectionStatusActionType) { return { ...state, status: action.payload.status, lateResponses: [] }; } else if (action.type === unsupervisedBackgroundActionType) { return { ...state, status: 'disconnected', lateResponses: [] }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates } = action.payload; return { ...state, queuedActivityUpdates: [ ...state.queuedActivityUpdates.filter((existingUpdate) => { - for (let activityUpdate of activityUpdates) { + for (const activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; } else if (action.type === updateActivityActionTypes.success) { const { payload } = action; return { ...state, queuedActivityUpdates: state.queuedActivityUpdates.filter( (activityUpdate) => !payload.activityUpdates.includes(activityUpdate), ), }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { ...state, queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { return { ...state, actualizedCalendarQuery: action.payload.calendarResult.calendarQuery, }; } else if ( action.type === registerActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { return { ...state, actualizedCalendarQuery: action.payload.calendarQuery, }; } else if (action.type === rehydrateActionType) { if (!action.payload || !action.payload.connection) { return state; } return { ...action.payload.connection, status: 'connecting', queuedActivityUpdates: [], lateResponses: [], showDisconnectedBar: false, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate } = action.payload; const lateResponsesSet = new Set(state.lateResponses); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } return { ...state, lateResponses: [...lateResponsesSet] }; } else if (action.type === updateDisconnectedBarActionType) { return { ...state, showDisconnectedBar: action.payload.visible }; } return state; } diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js index dd5c10bca..ed4567418 100644 --- a/lib/reducers/entry-reducer.js +++ b/lib/reducers/entry-reducer.js @@ -1,704 +1,704 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _groupBy from 'lodash/fp/groupBy'; import _isEqual from 'lodash/fp/isEqual'; import _map from 'lodash/fp/map'; import _mapKeys from 'lodash/fp/mapKeys'; import _mapValues from 'lodash/fp/mapValues'; import _omitBy from 'lodash/fp/omitBy'; import _pickBy from 'lodash/fp/pickBy'; import _sortBy from 'lodash/fp/sortBy'; import _union from 'lodash/fp/union'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createLocalEntryActionType, createEntryActionTypes, saveEntryActionTypes, concurrentModificationResetActionType, deleteEntryActionTypes, fetchRevisionsForEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { sendReportActionTypes, sendReportsActionTypes, } from '../actions/report-actions'; import { deleteThreadActionTypes, leaveThreadActionTypes, joinThreadActionTypes, changeThreadSettingsActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, } from '../actions/user-actions'; import { entryID, filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from '../shared/entry-utils'; import { threadInFilterList } from '../shared/thread-utils'; import type { RawEntryInfo, EntryStore, CalendarQuery, } from '../types/entry-types'; import type { BaseAction } from '../types/redux-types'; import { type ClientEntryInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { type RawThreadInfo } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { actionLogger } from '../utils/action-logger'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { dateString } from '../utils/date-utils'; import { values } from '../utils/objects'; import { sanitizeAction } from '../utils/sanitization'; function daysToEntriesFromEntryInfos(entryInfos: $ReadOnlyArray) { return _flow( _sortBy((['id', 'localID']: $ReadOnlyArray)), _groupBy((entryInfo: RawEntryInfo) => dateString(entryInfo.year, entryInfo.month, entryInfo.day), ), _mapValues((entryInfoGroup: RawEntryInfo[]) => _map(entryID)(entryInfoGroup), ), )([...entryInfos]); } function filterExistingDaysToEntriesWithNewEntryInfos( oldDaysToEntries: { [id: string]: string[] }, newEntryInfos: { [id: string]: RawEntryInfo }, ) { return _mapValues((entryIDs: string[]) => _filter((id: string) => newEntryInfos[id])(entryIDs), )(oldDaysToEntries); } function mergeNewEntryInfos( currentEntryInfos: { [id: string]: RawEntryInfo }, currentDaysToEntries: ?{ [day: string]: string[] }, newEntryInfos: $ReadOnlyArray, threadInfos: { [id: string]: RawThreadInfo }, ) { const mergedEntryInfos = {}; let someEntryUpdated = false; - for (let rawEntryInfo of newEntryInfos) { + for (const rawEntryInfo of newEntryInfos) { const serverID = rawEntryInfo.id; invariant(serverID, 'new entryInfos should have serverID'); const currentEntryInfo = currentEntryInfos[serverID]; let newEntryInfo; if (currentEntryInfo && currentEntryInfo.localID) { newEntryInfo = { id: serverID, // Try to preserve localIDs. This is because we use them as React // keys and changing React keys leads to loss of component state. localID: currentEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } else { newEntryInfo = { id: serverID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } if (_isEqual(currentEntryInfo)(newEntryInfo)) { mergedEntryInfos[serverID] = currentEntryInfo; } else { mergedEntryInfos[serverID] = newEntryInfo; someEntryUpdated = true; } } - for (let id in currentEntryInfos) { + for (const id in currentEntryInfos) { const newEntryInfo = mergedEntryInfos[id]; if (!newEntryInfo) { mergedEntryInfos[id] = currentEntryInfos[id]; } } - for (let id in mergedEntryInfos) { + for (const id in mergedEntryInfos) { const entryInfo = mergedEntryInfos[id]; if (!threadInFilterList(threadInfos[entryInfo.threadID])) { someEntryUpdated = true; delete mergedEntryInfos[id]; } } const daysToEntries = !currentDaysToEntries || someEntryUpdated ? daysToEntriesFromEntryInfos(values(mergedEntryInfos)) : currentDaysToEntries; const entryInfos = someEntryUpdated ? mergedEntryInfos : currentEntryInfos; return [entryInfos, daysToEntries]; } function reduceEntryInfos( entryStore: EntryStore, action: BaseAction, newThreadInfos: { [id: string]: RawThreadInfo }, ): EntryStore { const { entryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, } = entryStore; if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newLastUserInteractionCalendar = action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === setNewSessionActionType) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); const newLastUserInteractionCalendar = action.payload.sessionChange .cookieInvalidated ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fetchEntriesActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === updateCalendarQueryActionTypes.success) { const newLastUserInteractionCalendar = action.payload.calendarQuery ? Date.now() : lastUserInteractionCalendar; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === createLocalEntryActionType) { const entryInfo = action.payload; const localID = entryInfo.localID; invariant(localID, 'localID should be set in CREATE_LOCAL_ENTRY'); const newEntryInfos = { ...entryInfos, [localID]: entryInfo, }; const dayString = dateString( entryInfo.year, entryInfo.month, entryInfo.day, ); const newDaysToEntries = { ...daysToEntries, [dayString]: _union([localID])(daysToEntries[dayString]), }; return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === createEntryActionTypes.success) { const localID = action.payload.localID; const serverID = action.payload.entryID; // If an entry with this serverID already got into the store somehow // (likely through an unrelated request), we need to dedup them. let rekeyedEntryInfos; if (entryInfos[serverID]) { // It's fair to assume the serverID entry is newer than the localID // entry, and this probably won't happen often, so for now we can just // keep the serverID entry. rekeyedEntryInfos = _omitBy( (candidate: RawEntryInfo) => !candidate.id && candidate.localID === localID, )(entryInfos); } else if (entryInfos[localID]) { rekeyedEntryInfos = _mapKeys((oldKey: string) => entryInfos[oldKey].localID === localID ? serverID : oldKey, )(entryInfos); } else { // This happens if the entry is deauthorized before it's saved return entryStore; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( rekeyedEntryInfos, null, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === saveEntryActionTypes.success) { const serverID = action.payload.entryID; if ( !entryInfos[serverID] || !threadInFilterList(newThreadInfos[entryInfos[serverID].threadID]) ) { // This happens if the entry is deauthorized before it's saved return entryStore; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === concurrentModificationResetActionType) { const { payload } = action; if ( !entryInfos[payload.id] || !threadInFilterList(newThreadInfos[entryInfos[payload.id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return entryStore; } const newEntryInfos = { ...entryInfos, [payload.id]: { ...entryInfos[payload.id], text: payload.dbText, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === deleteEntryActionTypes.started) { const payload = action.payload; const id = payload.serverID && entryInfos[payload.serverID] ? payload.serverID : payload.localID; invariant(id, 'either serverID or localID should be set'); const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], deleted: true, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === deleteEntryActionTypes.success && action.payload) { const { payload } = action; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fetchRevisionsForEntryActionTypes.success) { const id = action.payload.entryID; if ( !entryInfos[id] || !threadInFilterList(newThreadInfos[entryInfos[id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return entryStore; } // Make sure the entry is in sync with its latest revision const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], text: action.payload.text, deleted: action.payload.deleted, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === restoreEntryActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === joinThreadActionTypes.success ) { const { calendarResult } = action.payload; if (calendarResult) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, calendarResult.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } } else if (action.type === incrementalStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos( action.payload.deltaEntryInfos, action.payload.updatesResult.newUpdates, ), newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === processUpdatesActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.newUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fullStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return entryStore; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = inconsistencyReports.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === inconsistencyReports.length) { return entryStore; } return { entryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports: updatedReports, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return entryStore; } const { rawEntryInfos, deleteEntryIDs } = checkStateRequest.stateChanges; if (!rawEntryInfos && !deleteEntryIDs) { return entryStore; } let updatedEntryInfos = { ...entryInfos }; if (deleteEntryIDs) { - for (let deleteEntryID of deleteEntryIDs) { + for (const deleteEntryID of deleteEntryIDs) { delete updatedEntryInfos[deleteEntryID]; } } let updatedDaysToEntries; if (rawEntryInfos) { [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( updatedEntryInfos, null, rawEntryInfos, newThreadInfos, ); } else { updatedDaysToEntries = daysToEntriesFromEntryInfos( values(updatedEntryInfos), ); } const newInconsistencies = findInconsistencies( action, entryInfos, updatedEntryInfos, action.payload.calendarQuery, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports: [...inconsistencyReports, ...newInconsistencies], }; } return entryStore; } function mergeUpdateEntryInfos( entryInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, ): RawEntryInfo[] { const entryIDs = new Set(entryInfos.map((entryInfo) => entryInfo.id)); const mergedEntryInfos = [...entryInfos]; - for (let updateInfo of newUpdates) { + for (const updateInfo of newUpdates) { if (updateInfo.type === updateTypes.JOIN_THREAD) { - for (let entryInfo of updateInfo.rawEntryInfos) { + for (const entryInfo of updateInfo.rawEntryInfos) { if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { entryInfo } = updateInfo; if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } return mergedEntryInfos; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { [id: string]: RawEntryInfo }, afterStateCheck: { [id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): ClientEntryInconsistencyReportCreationRequest[] { // We don't want to bother reporting an inconsistency if it's just because of // extraneous EntryInfos (not within the current calendarQuery) on either side const filteredBeforeResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeStateCheck)), calendarQuery, ); const filteredAfterResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(afterStateCheck)), calendarQuery, ); if (_isEqual(filteredBeforeResult)(filteredAfterResult)) { return emptyArray; } return [ { type: reportTypes.ENTRY_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeAction(action), calendarQuery, pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function markDeletedEntries( entryInfos: { [id: string]: RawEntryInfo }, deletedEntryIDs: $ReadOnlyArray, ): { [id: string]: RawEntryInfo } { let result = entryInfos; - for (let deletedEntryID of deletedEntryIDs) { + for (const deletedEntryID of deletedEntryIDs) { const entryInfo = entryInfos[deletedEntryID]; if (!entryInfo || entryInfo.deleted) { continue; } result = { ...result, [deletedEntryID]: { ...entryInfo, deleted: true, }, }; } return result; } export { daysToEntriesFromEntryInfos, reduceEntryInfos }; diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 58ba16f4a..7d6ecff18 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,925 +1,925 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference'; import _flow from 'lodash/fp/flow'; import _isEqual from 'lodash/fp/isEqual'; import _keyBy from 'lodash/fp/keyBy'; import _map from 'lodash/fp/map'; import _mapKeys from 'lodash/fp/mapKeys'; import _mapValues from 'lodash/fp/mapValues'; import _omit from 'lodash/fp/omit'; import _omitBy from 'lodash/fp/omitBy'; import _orderBy from 'lodash/fp/orderBy'; import _pick from 'lodash/fp/pick'; import _pickBy from 'lodash/fp/pickBy'; import _uniq from 'lodash/fp/uniq'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, } from '../actions/message-actions'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, } from '../actions/thread-actions'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { messageID, combineTruncationStatuses, sortMessageInfoList, } from '../shared/message-utils'; import { threadHasPermission, threadInChatList } from '../shared/thread-utils'; import threadWatcher from '../shared/thread-watcher'; import { unshimMessageInfos } from '../shared/unshim-utils'; import { type RawMessageInfo, type LocalMessageInfo, type MessageStore, type MessageTruncationStatus, type MessagesResponse, messageTruncationStatus, messageTypes, defaultNumberPerThread, } from '../types/message-types'; import type { RawImagesMessageInfo } from '../types/messages/images'; import type { RawMediaMessageInfo } from '../types/messages/media'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { type RawThreadInfo, threadPermissions } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { setNewSessionActionType } from '../utils/action-utils'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); const sortMessageIDs = (messages: { [id: string]: RawMessageInfo }) => _orderBy([(id: string) => messages[id].time, (id: string) => id])([ 'desc', 'desc', ]); // Input must already be ordered! function threadsToMessageIDsFromMessageInfos( orderedMessageInfos: $ReadOnlyArray, ): { [threadID: string]: string[] } { const threads: { [threadID: string]: string[] } = {}; - for (let messageInfo of orderedMessageInfos) { + for (const messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threads[messageInfo.threadID]) { threads[messageInfo.threadID] = [key]; } else { threads[messageInfo.threadID].push(key); } } return threads; } function threadIsWatched( threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadInfo.id)) ); } function freshMessageStore( messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { [threadID: string]: RawThreadInfo }, ): MessageStore { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedMessageInfos, ); const lastPruned = Date.now(); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } return { messages, threads, local: {}, currentAsOf }; } // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { [threadID: string]: RawThreadInfo }, actionType: *, ): MessageStore { const unshimmed = unshimMessageInfos(newMessageInfos); const localIDsToServerIDs: Map = new Map(); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); const currentMessageInfo = oldMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? oldMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmed); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const mustResortThreadMessageIDs = []; const lastPruned = Date.now(); const watchedIDs = threadWatcher.getWatchedIDs(); const local = {}; const threads = _flow( _pickBy((messageIDs: string[], threadID: string) => threadIsWatched(threadInfos[threadID], watchedIDs), ), _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { if (actionType === fetchMessagesBeforeCursorActionTypes.success) { // Well, this is weird. Somehow fetchMessagesBeforeCursor got called // for a thread that doesn't exist in the messageStore. How did this // happen? How do we even know what cursor to use if we didn't have // any messages? Anyways, the messageStore is predicated on the // principle that it is current. We can't create a ThreadMessageInfo // for a thread if we can't guarantee this, as the client has no UX // for endReached, only for startReached. We'll have to bail out here. return null; } return { messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map((oldID) => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. return { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); - for (let id of oldNotInNew) { + for (const id of oldNotInNew) { const oldMessageInfo = oldMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } return { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; mustResortThreadMessageIDs.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy((thread) => !!thread), )(threadsToMessageIDs); - for (let threadID in oldMessageStore.threads) { + for (const threadID in oldMessageStore.threads) { if ( threads[threadID] || !threadIsWatched(threadInfos[threadID], watchedIDs) ) { continue; } let thread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; - for (let id of thread.messageIDs) { + for (const id of thread.messageIDs) { const messageInfo = oldMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); - for (let threadID of mustResortThreadMessageIDs) { + for (const threadID of mustResortThreadMessageIDs) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, oldMessageStore.currentAsOf, ); return { messages, threads, local, currentAsOf }; } function filterByNewThreadInfos( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { const watchedIDs = threadWatcher.getWatchedIDs(); const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => threadIsWatched(threadInfo, watchedIDs), )(threadInfos); const messageIDsToRemove = []; - for (let threadID in messageStore.threads) { + for (const threadID in messageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } - for (let id of messageStore.threads[threadID].messageIDs) { + for (const id of messageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } return { messages: _omit(messageIDsToRemove)(messageStore.messages), threads: _pick(Object.keys(watchedThreadInfos))(messageStore.threads), local: _omit(messageIDsToRemove)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { const messagesResult = action.payload.messagesResult; return freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return messageStore; } const messagesResult = mergeUpdatesIntoMessagesResult( action.payload.messagesResult, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return messageStore; } const mergedMessageInfos = []; const mergedTruncationStatuses = {}; const { newUpdates } = action.payload.updatesResult; - for (let updateInfo of newUpdates) { + for (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } - for (let messageInfo of updateInfo.rawMessageInfos) { + for (const messageInfo of updateInfo.rawMessageInfos) { mergedMessageInfos.push(messageInfo); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } if (Object.keys(mergedTruncationStatuses).length === 0) { return messageStore; } const newMessageStore = mergeNewMessages( messageStore, mergedMessageInfos, mergedTruncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, action.type, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { return filterByNewThreadInfos(messageStore, newThreadInfos); } else if (action.type === newThreadActionTypes.success) { const { newThreadID } = action.payload; const truncationStatuses = {}; - for (let messageInfo of action.payload.newMessageInfos) { + for (const messageInfo of action.payload.newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageInfo.threadID === newThreadID ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, action.payload.newMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; - for (let messageInfo of action.payload.rawMessageInfos) { + for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if ( action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } } else if (action.type === restoreEntryActionTypes.success) { const { threadID } = action.payload; return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === joinThreadActionTypes.success) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started ) { const { payload } = action; const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const threads = { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: sortMessageIDs(messages)( messageStore.threads[threadID].messageIDs, ), }, }; return { ...messageStore, messages, threads, local }; } const { messageIDs } = messageStore.threads[threadID]; - for (let existingMessageID of messageIDs) { + for (const existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return messageStore; } } return { messages: { ...messageStore.messages, [localID]: payload, }, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: [localID, ...messageStore.threads[threadID].messageIDs], }, }, local: messageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success ) { const { payload } = action; const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return messageStore; } newMessages[payload.serverID] = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, sortMessageIDs(newMessages), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const currentAsOf = payload.interface === 'socket' ? Math.max(payload.time, messageStore.currentAsOf) : messageStore.currentAsOf; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); return { messages: newMessages, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }, local, currentAsOf, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; - for (let messageInfo of action.payload.rawMessageInfos) { + for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const newMessageStore = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; - let newThreads = { ...messageStore.threads }; - for (let threadID of action.payload.threadIDs) { + const newThreads = { ...messageStore.threads }; + for (const threadID of action.payload.threadIDs) { let thread = newThreads[threadID]; if (!thread) { continue; } thread = { ...thread, lastPruned: now }; const newMessageIDs = [...thread.messageIDs]; const removed = newMessageIDs.splice(defaultNumberPerThread); if (removed.length > 0) { thread = { ...thread, messageIDs: newMessageIDs, startReached: false, }; } for (const id of removed) { messageIDsToPrune.push(id); } newThreads[threadID] = thread; } return { messages: _omit(messageIDsToPrune)(messageStore.messages), threads: newThreads, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let replaced = false; const media = []; - for (let singleMedia of message.media) { + for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if (singleMedia.type === 'photo') { replaced = true; media.push({ ...singleMedia, ...mediaUpdate, }); } else if (singleMedia.type === 'video') { replaced = true; media.push({ ...singleMedia, ...mediaUpdate, }); } } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); return { ...messageStore, messages: { ...messageStore.messages, [id]: { ...message, media, }, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = action.payload; return { ...messageStore, messages: { ...messageStore.messages, [messageInfo.localID]: messageInfo, }, threads: { ...messageStore.threads, [messageInfo.threadID]: { ...messageStore.threads[messageInfo.threadID], messageIDs: [ messageInfo.localID, ...messageStore.threads[messageInfo.threadID].messageIDs, ], }, }, }; } else if (action.type === rehydrateActionType) { // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const { messages, threads, local } = messageStore; const newMessages = {}; let newThreads = threads, newLocal = local; - for (let id in messages) { + for (const id in messages) { const message = messages[id]; if ( (message.type !== messageTypes.IMAGES && message.type !== messageTypes.MULTIMEDIA) || message.id ) { newMessages[id] = message; continue; } const { threadID } = message; newThreads = { ...newThreads, [threadID]: { ...newThreads[threadID], messageIDs: newThreads[threadID].messageIDs.filter( (curMessageID) => curMessageID !== id, ), }, }; newLocal = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(newLocal); } if (newThreads === threads) { return messageStore; } return { ...messageStore, messages: newMessages, threads: newThreads, local: newLocal, }; } return messageStore; } function mergeUpdatesIntoMessagesResult( messagesResult: MessagesResponse, newUpdates: $ReadOnlyArray, ): MessagesResponse { const messageIDs = new Set( messagesResult.rawMessageInfos.map((messageInfo) => messageInfo.id), ); const mergedMessageInfos = [...messagesResult.rawMessageInfos]; const mergedTruncationStatuses = { ...messagesResult.truncationStatuses }; - for (let updateInfo of newUpdates) { + for (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } - for (let messageInfo of updateInfo.rawMessageInfos) { + for (const messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, currentAsOf: messagesResult.currentAsOf, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index 093cbe4c0..ff8ed70dd 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,342 +1,342 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions'; import { saveMessagesActionType } from '../actions/message-actions'; import { sendReportActionTypes, sendReportsActionTypes, } from '../actions/report-actions'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions'; import type { BaseAction } from '../types/redux-types'; import { type ClientThreadInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import type { RawThreadInfo, ThreadStore } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { actionLogger } from '../utils/action-logger'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { sanitizeAction } from '../utils/sanitization'; function reduceThreadUpdates( threadInfos: { [id: string]: RawThreadInfo }, payload: { +updatesResult: { +newUpdates: $ReadOnlyArray } }, ): { [id: string]: RawThreadInfo } { const newState = { ...threadInfos }; let someThreadUpdated = false; - for (let update of payload.updatesResult.newUpdates) { + for (const update of payload.updatesResult.newUpdates) { if ( (update.type === updateTypes.UPDATE_THREAD || update.type === updateTypes.JOIN_THREAD) && !_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo) ) { someThreadUpdated = true; newState[update.threadInfo.id] = update.threadInfo; } else if ( update.type === updateTypes.UPDATE_THREAD_READ_STATUS && threadInfos[update.threadID] && threadInfos[update.threadID].currentUser.unread !== update.unread ) { someThreadUpdated = true; newState[update.threadID] = { ...threadInfos[update.threadID], currentUser: { ...threadInfos[update.threadID].currentUser, unread: update.unread, }, }; } else if ( update.type === updateTypes.DELETE_THREAD && threadInfos[update.threadID] ) { someThreadUpdated = true; delete newState[update.threadID]; } else if (update.type === updateTypes.DELETE_ACCOUNT) { - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const newMembers = threadInfo.members.filter( (member) => member.id !== update.deletedUserID, ); if (newMembers.length < threadInfo.members.length) { someThreadUpdated = true; newState[threadID] = { ...threadInfo, members: newMembers, }; } } } } if (!someThreadUpdated) { return threadInfos; } return newState; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { [id: string]: RawThreadInfo }, afterStateCheck: { [id: string]: RawThreadInfo }, ): ClientThreadInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return emptyArray; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeAction(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } export default function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): ThreadStore { if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { if (_isEqual(state.threadInfos)(action.payload.threadInfos)) { return state; } return { threadInfos: action.payload.threadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return state; } return { threadInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success ) { if (action.payload.updatesResult.newUpdates.length === 0) { return state; } return { threadInfos: reduceThreadUpdates(state.threadInfos, action.payload), inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === updateSubscriptionActionTypes.success) { const newThreadInfos = { ...state.threadInfos, [action.payload.threadID]: { ...state.threadInfos[action.payload.threadID], currentUser: { ...state.threadInfos[action.payload.threadID].currentUser, subscription: action.payload.subscription, }, }, }; return { threadInfos: newThreadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); - for (let messageInfo of action.payload.rawMessageInfos) { + for (const messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos = {}; - for (let [threadID, mostRecentTime] of threadIDToMostRecentTime) { + for (const [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { return { threadInfos: { ...state.threadInfos, ...changedThreadInfos, }, inconsistencyReports: state.inconsistencyReports, }; } } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = state.inconsistencyReports.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === state.inconsistencyReports.length) { return state; } return { threadInfos: state.threadInfos, inconsistencyReports: updatedReports, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return state; } const newThreadInfos = { ...state.threadInfos }; if (rawThreadInfos) { - for (let rawThreadInfo of rawThreadInfos) { + for (const rawThreadInfo of rawThreadInfos) { newThreadInfos[rawThreadInfo.id] = rawThreadInfo; } } if (deleteThreadIDs) { - for (let deleteThreadID of deleteThreadIDs) { + for (const deleteThreadID of deleteThreadIDs) { delete newThreadInfos[deleteThreadID]; } } const newInconsistencies = findInconsistencies( action, state.threadInfos, newThreadInfos, ); return { threadInfos: newThreadInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos = {}; - for (let setToUnread of action.payload.result.unfocusedToUnread) { + for (const setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return state; } return { threadInfos: { ...state.threadInfos, ...updatedThreadInfos }, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread, }, }, }, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return state; } const updatedUser = { ...currentUser, unread: true, }; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: updatedUser, }, }, }; } return state; } diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 8434af154..aa79cbc73 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,236 +1,236 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _keyBy from 'lodash/fp/keyBy'; import { joinThreadActionTypes, newThreadActionTypes, } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, changeUserSettingsActionTypes, } from '../actions/user-actions'; import type { BaseAction } from '../types/redux-types'; import { type UserInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { updateTypes, processUpdatesActionType } from '../types/update-types'; import type { CurrentUserInfo, UserStore, UserInfos, } from '../types/user-types'; import { actionLogger } from '../utils/action-logger'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { sanitizeAction } from '../utils/sanitization'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { - for (let update of action.payload.updatesResult.newUpdates) { + for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo)(state) ) { return update.currentUserInfo; } } } else if (action.type === changeUserSettingsActionTypes.success) { invariant( state && !state.anonymous, "can't change settings if not logged in", ); const email = action.payload.email; if (!email) { return state; } return { id: state.id, username: state.username, email: email, emailVerified: false, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } return state; } function findInconsistencies( action: BaseAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, ): UserInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return []; } return [ { type: reportTypes.USER_INCONSISTENCY, platformDetails: getConfig().platformDetails, action: sanitizeAction(action), beforeStateCheck, afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function reduceUserInfos(state: UserStore, action: BaseAction): UserStore { if ( action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; if (!_isEqual(state.userInfos)(updated)) { return { ...state, userInfos: updated, }; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.userInfos).length === 0) { return state; } return { userInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); if (!_isEqual(state.userInfos)(newUserInfos)) { return { userInfos: newUserInfos, inconsistencyReports: state.inconsistencyReports, }; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; - for (let update of action.payload.updatesResult.newUpdates) { + for (const update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } if (!_isEqual(state.userInfos)(updated)) { return { userInfos: updated, inconsistencyReports: state.inconsistencyReports, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return state; } const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } const newInconsistencies = findInconsistencies( action, state.userInfos, newUserInfos, ); return { userInfos: newUserInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/calendar-filter-selectors.js b/lib/selectors/calendar-filter-selectors.js index a8da92622..1cf250d7f 100644 --- a/lib/selectors/calendar-filter-selectors.js +++ b/lib/selectors/calendar-filter-selectors.js @@ -1,104 +1,104 @@ // @flow import { createSelector } from 'reselect'; import { type CalendarFilter, calendarThreadFilterTypes, type CalendarThreadFilterType, } from '../types/filter-types'; import type { BaseAppState } from '../types/redux-types'; function filteredThreadIDs( calendarFilters: $ReadOnlyArray, ): ?Set { let threadIDs = []; let threadListFilterExists = false; - for (let filter of calendarFilters) { + for (const filter of calendarFilters) { if (filter.type === calendarThreadFilterTypes.THREAD_LIST) { threadListFilterExists = true; threadIDs = [...threadIDs, ...filter.threadIDs]; } } if (!threadListFilterExists) { return null; } return new Set(threadIDs); } const filteredThreadIDsSelector: ( state: BaseAppState<*>, ) => ?Set = createSelector( (state: BaseAppState<*>) => state.calendarFilters, filteredThreadIDs, ); function filterFilters( calendarFilters: $ReadOnlyArray, filterTypeToExclude: CalendarThreadFilterType, ): $ReadOnlyArray { const filteredFilters = []; - for (let filter of calendarFilters) { + for (const filter of calendarFilters) { if (filter.type !== filterTypeToExclude) { filteredFilters.push(filter); } } return filteredFilters; } function nonThreadCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.THREAD_LIST); } const nonThreadCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonThreadCalendarFilters, ); function nonExcludeDeletedCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.NOT_DELETED); } const nonExcludeDeletedCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonExcludeDeletedCalendarFilters, ); function filterExists( calendarFilters: $ReadOnlyArray, filterType: CalendarThreadFilterType, ): boolean { - for (let filter of calendarFilters) { + for (const filter of calendarFilters) { if (filter.type === filterType) { return true; } } return false; } const includeDeletedSelector: ( state: BaseAppState<*>, ) => boolean = createSelector( (state: BaseAppState<*>) => state.calendarFilters, (calendarFilters: $ReadOnlyArray) => !filterExists(calendarFilters, calendarThreadFilterTypes.NOT_DELETED), ); export { filteredThreadIDs, filteredThreadIDsSelector, nonThreadCalendarFilters, nonThreadCalendarFiltersSelector, nonExcludeDeletedCalendarFilters, nonExcludeDeletedCalendarFiltersSelector, filterExists, includeDeletedSelector, }; diff --git a/lib/selectors/calendar-selectors.js b/lib/selectors/calendar-selectors.js index 831815c29..1758afeb0 100644 --- a/lib/selectors/calendar-selectors.js +++ b/lib/selectors/calendar-selectors.js @@ -1,85 +1,85 @@ // @flow import { createSelector } from 'reselect'; import { rawEntryInfoWithinActiveRange } from '../shared/entry-utils'; import SearchIndex from '../shared/search-index'; import { threadInFilterList } from '../shared/thread-utils'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; import { type FilterThreadInfo } from '../types/filter-types'; import type { BaseAppState } from '../types/redux-types'; import type { ThreadInfo } from '../types/thread-types'; import { values } from '../utils/objects'; import { currentCalendarQuery } from './nav-selectors'; import { threadInfoSelector } from './thread-selectors'; const filterThreadInfos: ( state: BaseAppState<*>, ) => ( calendarActive: boolean, ) => $ReadOnlyArray = createSelector( threadInfoSelector, currentCalendarQuery, (state: BaseAppState<*>) => state.entryStore.entryInfos, ( threadInfos: { [id: string]: ThreadInfo }, calendarQueryFunc: (calendarActive: boolean) => CalendarQuery, rawEntryInfos: { [id: string]: RawEntryInfo }, ) => (calendarActive: boolean) => { const calendarQuery = calendarQueryFunc(calendarActive); const result: { [threadID: string]: FilterThreadInfo } = {}; - for (let entryID in rawEntryInfos) { + for (const entryID in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[entryID]; if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { continue; } const threadID = rawEntryInfo.threadID; const threadInfo = threadInfos[rawEntryInfo.threadID]; if (!threadInFilterList(threadInfo)) { continue; } if (result[threadID]) { result[threadID].numVisibleEntries++; } else { result[threadID] = { threadInfo, numVisibleEntries: 1, }; } } - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!result[threadID] && threadInFilterList(threadInfo)) { result[threadID] = { threadInfo, numVisibleEntries: 0, }; } } return values(result).sort( (first: FilterThreadInfo, second: FilterThreadInfo) => second.numVisibleEntries - first.numVisibleEntries, ); }, ); const filterThreadSearchIndex: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => SearchIndex = createSelector( filterThreadInfos, ( threadInfoFunc: ( calendarActive: boolean, ) => $ReadOnlyArray, ) => (calendarActive: boolean) => { const threadInfos = threadInfoFunc(calendarActive); const searchIndex = new SearchIndex(); for (const filterThreadInfo of threadInfos) { const { threadInfo } = filterThreadInfo; searchIndex.addEntry(threadInfo.id, threadInfo.uiName); } return searchIndex; }, ); export { filterThreadInfos, filterThreadSearchIndex }; diff --git a/lib/selectors/loading-selectors.js b/lib/selectors/loading-selectors.js index ebe0c97f2..223f1e1e6 100644 --- a/lib/selectors/loading-selectors.js +++ b/lib/selectors/loading-selectors.js @@ -1,97 +1,97 @@ // @flow import invariant from 'invariant'; import _includes from 'lodash/fp/includes'; import _isEmpty from 'lodash/fp/isEmpty'; import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import { registerFetchKey } from '../reducers/loading-reducer'; import type { LoadingStatus } from '../types/loading-types'; import type { BaseAppState } from '../types/redux-types'; import type { ActionTypes } from '../utils/action-utils'; import { values } from '../utils/objects'; function loadingStatusFromInfo(loadingStatusInfo: { [idx: number]: LoadingStatus, }): LoadingStatus { if (_isEmpty(loadingStatusInfo)) { return 'inactive'; } else if (_includes('error')(loadingStatusInfo)) { return 'error'; } else { return 'loading'; } } // This is the key used to store the Promise state in Redux function getTrackingKey( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) { if (overrideKey) { return overrideKey; } const startMatch = actionTypes.started.match(/(.*)_STARTED/); invariant( startMatch && startMatch[1], 'actionTypes.started should always end with _STARTED', ); return startMatch[1]; } const baseCreateLoadingStatusSelector = ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ): ((state: BaseAppState<*>) => LoadingStatus) => { // This makes sure that reduceLoadingStatuses tracks this action registerFetchKey(actionTypes); const trackingKey = getTrackingKey(actionTypes, overrideKey); return createSelector( (state: BaseAppState<*>) => state.loadingStatuses[trackingKey], (loadingStatusInfo: { [idx: number]: LoadingStatus }) => loadingStatusFromInfo(loadingStatusInfo), ); }; const createLoadingStatusSelector: ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) => (state: BaseAppState<*>) => LoadingStatus = _memoize( baseCreateLoadingStatusSelector, getTrackingKey, ); function combineLoadingStatuses( ...loadingStatuses: $ReadOnlyArray ): LoadingStatus { let errorExists = false; - for (let loadingStatus of loadingStatuses) { + for (const loadingStatus of loadingStatuses) { if (loadingStatus === 'loading') { return 'loading'; } if (loadingStatus === 'error') { errorExists = true; } } return errorExists ? 'error' : 'inactive'; } const globalLoadingStatusSelector: ( state: BaseAppState<*>, ) => LoadingStatus = createSelector( (state: BaseAppState<*>) => state.loadingStatuses, (loadingStatusInfos: { [key: string]: { [idx: number]: LoadingStatus }, }): LoadingStatus => { const loadingStatusInfoValues = values(loadingStatusInfos); const loadingStatuses = loadingStatusInfoValues.map(loadingStatusFromInfo); return combineLoadingStatuses(...loadingStatuses); }, ); export { createLoadingStatusSelector, globalLoadingStatusSelector, combineLoadingStatuses, }; diff --git a/lib/selectors/local-id-selectors.js b/lib/selectors/local-id-selectors.js index a4e00aafe..8c89479eb 100644 --- a/lib/selectors/local-id-selectors.js +++ b/lib/selectors/local-id-selectors.js @@ -1,51 +1,51 @@ // @flow import invariant from 'invariant'; import type { BaseAppState } from '../types/redux-types'; const localIDExtractionRegex = /^local([0-9]+)$/; function numberFromLocalID(localID: string) { const matches = localIDExtractionRegex.exec(localID); invariant(matches && matches[1], `${localID} doesn't look like a localID`); return parseInt(matches[1], 10); } function highestLocalIDSelector(state: ?BaseAppState<*>): number { let highestLocalIDFound = -1; if (state && state.messageStore) { - for (let messageKey in state.messageStore.messages) { + for (const messageKey in state.messageStore.messages) { const messageInfo = state.messageStore.messages[messageKey]; if (!messageInfo.localID) { continue; } const { localID } = messageInfo; if (!localID) { continue; } const thisLocalID = numberFromLocalID(localID); if (thisLocalID > highestLocalIDFound) { highestLocalIDFound = thisLocalID; } } } if (state && state.entryStore) { - for (let entryKey in state.entryStore.entryInfos) { + for (const entryKey in state.entryStore.entryInfos) { const { localID } = state.entryStore.entryInfos[entryKey]; if (!localID) { continue; } const thisLocalID = numberFromLocalID(localID); if (thisLocalID > highestLocalIDFound) { highestLocalIDFound = thisLocalID; } } } return highestLocalIDFound; } export { numberFromLocalID, highestLocalIDSelector }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index 92f04d0e3..c72813236 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,179 +1,179 @@ // @flow import { createSelector } from 'reselect'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils'; import threadWatcher from '../shared/thread-watcher'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; import type { AppState } from '../types/redux-types'; import type { ClientThreadInconsistencyReportCreationRequest, ClientEntryInconsistencyReportCreationRequest, ClientReportCreationRequest, } from '../types/report-types'; import { serverRequestTypes, type ServerRequest, type ClientClientResponse, } from '../types/request-types'; import type { SessionState } from '../types/session-types'; import type { RawThreadInfo } from '../types/thread-types'; import type { CurrentUserInfo, UserInfos } from '../types/user-types'; import { getConfig } from '../utils/config'; import { values, hash } from '../utils/objects'; import { currentCalendarQuery } from './nav-selectors'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.inconsistencyReports, (state: AppState) => state.entryStore.inconsistencyReports, (state: AppState) => state.queuedReports, ( threadInconsistencyReports: $ReadOnlyArray, entryInconsistencyReports: $ReadOnlyArray, mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => [ ...threadInconsistencyReports, ...entryInconsistencyReports, ...mainQueuedReports, ], ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { [id: string]: RawThreadInfo }, entryInfos: { [id: string]: RawEntryInfo }, userInfos: UserInfos, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ): $ReadOnlyArray => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( (request) => request.type === serverRequestTypes.PLATFORM_DETAILS, ); - for (let serverRequest of serverRequests) { + for (const serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; - for (let key in serverRequest.hashesToCheck) { + for (const key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); } else if (key === 'userInfos') { hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); hashValue = hash(userInfos[userID]); } else { continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { - for (let entryID in filteredEntryInfos) { + for (const entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.userInfos) { - for (let userID in userInfos) { + for (const userID in userInfos) { const key = `userInfo|${userID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 0fb42afec..cafd63864 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,349 +1,349 @@ // @flow import _compact from 'lodash/fp/compact'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _mapValues from 'lodash/fp/mapValues'; import _orderBy from 'lodash/fp/orderBy'; import _some from 'lodash/fp/some'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { createEntryInfo } from '../shared/entry-utils'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, } from '../shared/thread-utils'; import type { EntryInfo } from '../types/entry-types'; import type { MessageStore, RawMessageInfo } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, type SidebarInfo, } from '../types/thread-types'; import { dateString, dateFromString } from '../utils/date-utils'; import { values } from '../utils/objects'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = []; - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, (inputThreadIDs: ?Set, threadInfos: ThreadInfo[]) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter((threadInfo) => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( onScreenThreadInfos, (threadInfos: ThreadInfo[]) => threadInfos.filter((threadInfo) => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: EntryInfo } = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: ( state: BaseAppState<*>, ) => { [dayString: string]: EntryInfo[] } = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { [id: string]: EntryInfo }, daysToEntries: { [day: string]: string[] }, startDateString: string, endDateString: string, onScreen: ThreadInfo[], includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; - for (let id in threadInfos) { + for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } - for (let messageID of thread.messageIDs) { + for (const messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } const sidebarInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createObjectSelector( childThreadInfos, (state: BaseAppState<*>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: ( state: BaseAppState<*>, ) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; - for (let member of members) { + for (const member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentReadThread( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentReadThreadSelector: ( state: BaseAppState<*>, ) => ?string = createSelector( (state: BaseAppState<*>) => state.messageStore, (state: BaseAppState<*>) => state.threadStore.threadInfos, mostRecentReadThread, ); const threadInfoFromSourceMessageIDSelector: ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { parentThreadID, sourceMessageID } = threadInfo; if (!parentThreadID || !sourceMessageID) { continue; } result[sourceMessageID] = threadInfo; } return result; }, ); export { threadInfoSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentReadThread, mostRecentReadThreadSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index ce093bb58..2320ee75a 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,215 +1,215 @@ // @flow import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import SearchIndex from '../shared/search-index'; import { getSingleOtherUser, memberHasAdminPowers, } from '../shared/thread-utils'; import type { BaseAppState } from '../types/redux-types'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type RelativeMemberInfo, threadTypes, } from '../types/thread-types'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, } from '../types/user-types'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos = []; - for (let userID of userIDs) { + for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; if (userID === viewerID) { relativeUserInfos.unshift({ id: userID, username, isViewer: true, }); } else { relativeUserInfos.push({ id: userID, username, isViewer: false, }); } } return relativeUserInfos; } const emptyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, ( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray => { const relativeMemberInfos = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { const isParentAdmin = memberHasAdminPowers(memberInfo); if (!memberInfo.role && !isParentAdmin) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: true, isSender: memberInfo.isSender, }); } else { relativeMemberInfos.push({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: false, isSender: memberInfo.isSender, }); } } return relativeMemberInfos; }, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: ( state: BaseAppState<*>, ) => { [id: string]: AccountUserInfo } = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); function searchIndexFromUserInfos(userInfos: { [id: string]: AccountUserInfo, }) { const searchIndex = new SearchIndex(); for (const id in userInfos) { searchIndex.addEntry(id, userInfos[id].username); } return searchIndex; } const userSearchIndexForPotentialMembers: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( userInfoSelectorForPotentialMembers, searchIndexFromUserInfos, ); const isLoggedIn = (state: BaseAppState<*>) => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const userStoreSearchIndex: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (userInfos: UserInfos) => { const searchIndex = new SearchIndex(); for (const id in userInfos) { const { username } = userInfos[id]; if (!username) { continue; } searchIndex.addEntry(id, username); } return searchIndex; }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<*>, ) => $ReadOnlySet = createSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, (state) => state.threadStore.threadInfos, (viewerID, threadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( thread.type !== threadTypes.PERSONAL || !thread.members.find((member) => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); export { userIDsToRelativeUserInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, isLoggedIn, userStoreSearchIndex, usersWithPersonalThreadSelector, }; diff --git a/lib/shared/entry-utils.js b/lib/shared/entry-utils.js index 9d308fce4..4ad22e295 100644 --- a/lib/shared/entry-utils.js +++ b/lib/shared/entry-utils.js @@ -1,264 +1,264 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import { filteredThreadIDs, nonThreadCalendarFilters, filterExists, } from '../selectors/calendar-filter-selectors'; import type { RawEntryInfo, EntryInfo, CalendarQuery, } from '../types/entry-types'; import { calendarThreadFilterTypes } from '../types/filter-types'; import type { UserInfos } from '../types/user-types'; import { dateString, getDate, dateFromString } from '../utils/date-utils'; type HasEntryIDs = { localID?: string, id?: string }; function entryKey(entryInfo: HasEntryIDs): string { if (entryInfo.localID) { return entryInfo.localID; } invariant(entryInfo.id, 'localID should exist if ID does not'); return entryInfo.id; } function entryID(entryInfo: HasEntryIDs): string { if (entryInfo.id) { return entryInfo.id; } invariant(entryInfo.localID, 'localID should exist if ID does not'); return entryInfo.localID; } function createEntryInfo( rawEntryInfo: RawEntryInfo, viewerID: ?string, userInfos: UserInfos, ): EntryInfo { const creatorInfo = userInfos[rawEntryInfo.creatorID]; return { id: rawEntryInfo.id, localID: rawEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creator: creatorInfo && creatorInfo.username, deleted: rawEntryInfo.deleted, }; } // Make sure EntryInfo is between startDate and endDate, and that if the // NOT_DELETED filter is active, the EntryInfo isn't deleted function rawEntryInfoWithinActiveRange( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { const entryInfoDate = getDate( rawEntryInfo.year, rawEntryInfo.month, rawEntryInfo.day, ); const startDate = dateFromString(calendarQuery.startDate); const endDate = dateFromString(calendarQuery.endDate); if (entryInfoDate < startDate || entryInfoDate > endDate) { return false; } if ( rawEntryInfo.deleted && filterExists(calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED) ) { return false; } return true; } function rawEntryInfoWithinCalendarQuery( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { return false; } const filterToThreadIDs = filteredThreadIDs(calendarQuery.filters); if (filterToThreadIDs && !filterToThreadIDs.has(rawEntryInfo.threadID)) { return false; } return true; } function filterRawEntryInfosByCalendarQuery( rawEntryInfos: { [id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): { [id: string]: RawEntryInfo } { let filtered = false; const filteredRawEntryInfos = {}; - for (let id in rawEntryInfos) { + for (const id in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[id]; if (!rawEntryInfoWithinCalendarQuery(rawEntryInfo, calendarQuery)) { filtered = true; continue; } filteredRawEntryInfos[id] = rawEntryInfo; } return filtered ? filteredRawEntryInfos : rawEntryInfos; } function usersInRawEntryInfos( entryInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); - for (let entryInfo of entryInfos) { + for (const entryInfo of entryInfos) { userIDs.add(entryInfo.creatorID); } return [...userIDs]; } // Note: fetchEntriesForSession expects that all of the CalendarQueries in the // resultant array either filter deleted entries or don't function calendarQueryDifference( oldCalendarQuery: CalendarQuery, newCalendarQuery: CalendarQuery, ): CalendarQuery[] { if (_isEqual(oldCalendarQuery)(newCalendarQuery)) { return []; } const deletedEntriesWereIncluded = filterExists( oldCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); const deletedEntriesAreIncluded = filterExists( newCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (!deletedEntriesWereIncluded && deletedEntriesAreIncluded) { // The new query includes all deleted entries, but the old one didn't. Since // we have no way to include ONLY deleted entries in a CalendarQuery, we // can't separate newCalendarQuery into a query for just deleted entries on // the old range, and a query for all entries on the full range. We'll have // to just query for the whole newCalendarQuery range directly. return [newCalendarQuery]; } const oldFilteredThreadIDs = filteredThreadIDs(oldCalendarQuery.filters); const newFilteredThreadIDs = filteredThreadIDs(newCalendarQuery.filters); if (oldFilteredThreadIDs && !newFilteredThreadIDs) { // The new query is for all thread IDs, but the old one had a THREAD_LIST. // Since we have no way to exclude particular thread IDs from a // CalendarQuery, we can't separate newCalendarQuery into a query for just // the new thread IDs on the old range, and a query for all the thread IDs // on the full range. We'll have to just query for the whole // newCalendarQuery range directly. return [newCalendarQuery]; } const difference = []; const oldStartDate = dateFromString(oldCalendarQuery.startDate); const oldEndDate = dateFromString(oldCalendarQuery.endDate); const newStartDate = dateFromString(newCalendarQuery.startDate); const newEndDate = dateFromString(newCalendarQuery.endDate); if ( oldFilteredThreadIDs && newFilteredThreadIDs && // This checks that there exists an intersection at all oldStartDate <= newEndDate && oldEndDate >= newStartDate ) { const newNotInOld = [...newFilteredThreadIDs].filter( (x) => !oldFilteredThreadIDs.has(x), ); if (newNotInOld.length > 0) { // In this case, we have added new threadIDs to the THREAD_LIST. // We should query the calendar range for these threads. const intersectionStartDate = oldStartDate < newStartDate ? newCalendarQuery.startDate : oldCalendarQuery.startDate; const intersectionEndDate = oldEndDate > newEndDate ? newCalendarQuery.endDate : oldCalendarQuery.endDate; difference.push({ startDate: intersectionStartDate, endDate: intersectionEndDate, filters: [ ...nonThreadCalendarFilters(newCalendarQuery.filters), { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: newNotInOld, }, ], }); } } if (newStartDate < oldStartDate) { const partialEndDate = new Date(oldStartDate.getTime()); partialEndDate.setDate(partialEndDate.getDate() - 1); difference.push({ filters: newCalendarQuery.filters, startDate: newCalendarQuery.startDate, endDate: dateString(partialEndDate), }); } if (newEndDate > oldEndDate) { const partialStartDate = new Date(oldEndDate.getTime()); partialStartDate.setDate(partialStartDate.getDate() + 1); difference.push({ filters: newCalendarQuery.filters, startDate: dateString(partialStartDate), endDate: newCalendarQuery.endDate, }); } return difference; } function serverEntryInfo(rawEntryInfo: RawEntryInfo): ?RawEntryInfo { const { id } = rawEntryInfo; if (!id) { return null; } const { localID, ...rest } = rawEntryInfo; return { ...rest }; // we only do this for Flow } function serverEntryInfosObject( array: $ReadOnlyArray, ): { [id: string]: RawEntryInfo } { const obj = {}; - for (let rawEntryInfo of array) { + for (const rawEntryInfo of array) { const entryInfo = serverEntryInfo(rawEntryInfo); if (!entryInfo) { continue; } const { id } = entryInfo; invariant(id, 'should be set'); obj[id] = entryInfo; } return obj; } export { entryKey, entryID, createEntryInfo, rawEntryInfoWithinActiveRange, rawEntryInfoWithinCalendarQuery, filterRawEntryInfosByCalendarQuery, usersInRawEntryInfos, calendarQueryDifference, serverEntryInfo, serverEntryInfosObject, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 3fd1870f7..e77d8cc1b 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,405 +1,405 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import _orderBy from 'lodash/fp/orderBy'; import { type ParserRules } from 'simple-markdown'; import { multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTypes, messageTruncationStatus, type RawComposableMessageInfo, } from '../types/message-types'; import type { ImagesMessageData } from '../types/messages/images'; import type { MediaMessageData } from '../types/messages/media'; import { type ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { codeBlockRegex } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { stringForUser } from './user-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, creator, { encodedThreadEntity, robotextForUsers, robotextForUser, threadInfo, }); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } const creator = { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { return _orderBy(['time', 'id'])(['desc', 'desc'])(messageInfos); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return messageSpecs[type].generatesNotifs; } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); - for (let messageInfo of messageInfos) { + for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; - for (let singleMedia of input.media) { + for (const singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); const messageSpec = messageSpecs[messageData.type]; return messageSpec.rawMessageInfoFromMessageData(messageData, input.id); } function stripLocalID(rawMessageInfo: RawComposableMessageInfo) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export type GetMessageTitleViewerContext = | 'global_viewer' | 'individual_viewer'; function getMessageTitle( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, viewerContext?: GetMessageTitleViewerContext = 'individual_viewer', ): string { const { messageTitle } = messageSpecs[messageInfo.type]; return messageTitle({ messageInfo, threadInfo, markdownRules, viewerContext, }); } function removeCreatorAsViewer(messageInfo: Info): Info { return { ...messageInfo, creator: { ...messageInfo.creator, isViewer: false }, }; } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, getMessageTitle, removeCreatorAsViewer, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 4a013d23f..ce08a8592 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1129 +1,1129 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import { type ParserRules } from 'simple-markdown'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { newThread, newThreadActionTypes } from '../actions/thread-actions'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors'; import { threadInfoSelector, threadInfoFromSourceMessageIDSelector, } from '../selectors/thread-selectors'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; import type { NewThreadRequest, NewThreadResult, OptimisticThreadInfo, } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, AccountUserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import type { DispatchActionPromise } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; import { firstLine } from '../utils/string-utils'; import { pluralize, trimText } from '../utils/text-utils'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function generatePendingThreadColor( userIDs: $ReadOnlyArray, viewerID: string, ) { const ids = [...userIDs, viewerID].sort().join('#'); let hash = 0; for (let i = 0; i < ids.length; i++) { hash = 1009 * hash + ids.charCodeAt(i) * 83; hash %= 1000000007; } const hashString = hash.toString(16); return hashString.substring(hashString.length - 6).padStart(6, '8'); } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } function threadIsPendingSidebar(threadInfo: ?ThreadInfo) { return ( threadInfo?.type === threadTypes.SIDEBAR && threadIsPending(threadInfo?.id) ); } function getPendingThreadOtherUsers(threadInfo: ThreadInfo | RawThreadInfo) { invariant(threadIsPending(threadInfo.id), 'Thread should be pending'); const otherUserIDs = threadInfo.id.split('/')[1]; invariant( otherUserIDs || threadInfo.type === threadTypes.SIDEBAR, 'Pending threads should contain other members id in its id', ); if (!otherUserIDs) { return []; } return otherUserIDs.split('+'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ) { if (!viewerID) { return undefined; } const otherMemberIDs = threadInfo.members .map((member) => member.id) .filter((id) => id !== viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getPendingThreadKey( memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ) { const membersBasedID = [...memberIDs].sort().join('+'); return sourceMessageID ? `${membersBasedID}/${sourceMessageID}` : membersBasedID; } type CreatePendingThreadArgs = {| +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadID?: ?string, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, |}; function createPendingThread({ viewerID, threadType, members, parentThreadID, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs) { const now = Date.now(); members = members ?? []; const memberIDs = members.map((member) => member.id); const threadID = `pending/${getPendingThreadKey(memberIDs, sourceMessageID)}`; const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs, viewerID), creationTime: now, parentThreadID: parentThreadID ?? null, members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...members.map((member) => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, }; const userInfos = {}; members.forEach((member) => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, viewerID: string, markdownRules: ParserRules, ) { const { id, username } = messageInfo.creator; const { id: parentThreadID, color } = threadInfo; const messageTitle = getMessageTitle( messageInfo, threadInfo, markdownRules, 'global_viewer', ); const threadName = trimText(messageTitle, 30); invariant(username, 'username should be set in createPendingSidebar'); const initialMemberUserInfo: GlobalAccountUserInfo = { id, username }; const creatorIsMember = userIsMember(threadInfo, id); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: creatorIsMember ? [initialMemberUserInfo] : [], parentThreadID, threadColor: color, name: threadName, sourceMessageID: messageInfo.id, }); } function pendingThreadType(numberOfOtherMembers: number) { return numberOfOtherMembers === 1 ? threadTypes.PERSONAL : threadTypes.CHAT_SECRET; } async function createRealThreadFromPendingThread( threadInfo: ThreadInfo, dispatchActionPromise: DispatchActionPromise, createNewThread: (request: NewThreadRequest) => Promise, sourceMessageID: ?string, handleError?: () => mixed, ): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = getPendingThreadOtherUsers(threadInfo); try { let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } catch (e) { if (handleError) { handleError(); return undefined; } else { throw e; } } } function useRealThreadCreator( thread: ?OptimisticThreadInfo, handleError?: () => mixed, ) { const creationResultRef = React.useRef(); const threadInfo = thread?.threadInfo; const threadID = threadInfo?.id; const creationResult = creationResultRef.current; const serverThreadID = React.useMemo(() => { if (threadID && !threadIsPending(threadID)) { return threadID; } else if (creationResult && creationResult.pendingThreadID === threadID) { return creationResult.serverThreadID; } return null; }, [threadID, creationResult]); const sourceMessageID = thread?.sourceMessageID; const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return React.useCallback(async () => { if (serverThreadID) { return serverThreadID; } else if (!threadInfo) { return null; } const newThreadID = await createRealThreadFromPendingThread( threadInfo, dispatchActionPromise, callNewThread, sourceMessageID, handleError, ); creationResultRef.current = { pendingThreadID: threadInfo.id, serverThreadID: newThreadID, }; return newThreadID; }, [ callNewThread, dispatchActionPromise, handleError, serverThreadID, sourceMessageID, threadInfo, ]); } type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo: RawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const uiName = threadInfo.name ? threadInfo.name : robotextName(threadInfo, viewerID, userInfos); return firstLine(uiName); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { const threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo.sourceMessageID = sourceMessageID; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { const rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } return rawThreadInfo; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); - for (let member of threadInfo.members) { + for (const member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { return memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { if (!threadInfo) { return false; } return _find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter((member) => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD, threadPermissions.CREATE_SUBTHREADS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } - for (let member of threadInfo.members) { + for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType) { return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'thread'; } function threadLabel(threadType: ThreadType) { if (threadType === threadTypes.CHAT_SECRET) { return 'Secret'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Sidebar'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if (threadType === threadTypes.CHAT_NESTED_OPEN) { return 'Open'; } invariant(false, `unexpected threadType ${threadType}`); } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } function useThreadCandidates(threadInfos: { [id: string]: ThreadInfo }) { return React.useMemo(() => { const infos = new Map(); for (const threadID in threadInfos) { const info = threadInfos[threadID]; if (info.parentThreadID || threadHasAdminRole(info)) { continue; } const key = getPendingThreadKey(info.members.map((member) => member.id)); const indexedThread = infos.get(key); if (!indexedThread || info.creationTime < indexedThread.creationTime) { infos.set(key, info); } } return infos; }, [threadInfos]); } function useSidebarCandidate(sourceMessageID: ?string) { return useSelector((state) => { if (!sourceMessageID) { return null; } return threadInfoFromSourceMessageIDSelector(state)[sourceMessageID]; }); } type UseCurrentThreadInfoArgs = {| +baseThreadInfo: ?ThreadInfo, +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +sourceMessageID: ?string, |}; function useCurrentThreadInfo({ baseThreadInfo, searching, userInfoInputArray, sourceMessageID, }: UseCurrentThreadInfoArgs) { const threadInfos = useSelector(threadInfoSelector); const threadCandidates = useThreadCandidates(threadInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector((state) => state.userStore.userInfos); const sidebarCandidate = useSidebarCandidate(sourceMessageID); const latestThreadInfo = React.useMemo((): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const threadInfoFromParams = baseThreadInfo; const threadInfoFromStore = threadInfos[threadInfoFromParams.id]; if (threadInfoFromStore) { return threadInfoFromStore; } else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) { return undefined; } if (sidebarCandidate) { return sidebarCandidate; } const pendingThreadMemberIDs = searching ? [...userInfoInputArray.map((user) => user.id), viewerID] : threadInfoFromParams.members.map((member) => member.id); const threadKey = getPendingThreadKey(pendingThreadMemberIDs); if ( threadInfoFromParams.type !== threadTypes.SIDEBAR && threadCandidates.get(threadKey) ) { return threadCandidates.get(threadKey); } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : threadInfoFromParams; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, viewerID, searching, userInfoInputArray, threadCandidates, sidebarCandidate, userInfos, ]); return latestThreadInfo ? latestThreadInfo : baseThreadInfo; } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.CHAT_NESTED_OPEN || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ) { const messageCreatorUserInfo = useSelector( (state) => state.userStore.userInfos[messageInfo.creator.id], ); const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ) { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, threadIsPendingSidebar, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, useRealThreadCreator, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, useSidebarCandidate, useCurrentThreadInfo, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, }; diff --git a/lib/shared/thread-watcher.js b/lib/shared/thread-watcher.js index 6510ca1db..475155866 100644 --- a/lib/shared/thread-watcher.js +++ b/lib/shared/thread-watcher.js @@ -1,43 +1,43 @@ // @flow const removalDelay = 10000; class ThreadWatcher { watchedIDs: Map = new Map(); pendingRemovals: Map = new Map(); watchID(id: string) { this.pendingRemovals.delete(id); const currentCount = this.watchedIDs.get(id); const nextCount = currentCount ? currentCount + 1 : 1; this.watchedIDs.set(id, nextCount); } removeID(id: string) { const currentCount = this.watchedIDs.get(id); if (!currentCount) { return; } const nextCount = currentCount - 1; this.watchedIDs.set(id, nextCount); if (nextCount === 0) { this.pendingRemovals.set(id, Date.now()); } } getWatchedIDs(): string[] { const now = Date.now(); - for (let tuple of this.pendingRemovals) { + for (const tuple of this.pendingRemovals) { if (tuple[1] + removalDelay > now) { continue; } this.pendingRemovals.delete(tuple[0]); this.watchedIDs.delete(tuple[0]); } return [...this.watchedIDs.keys()]; } } const threadWatcher = new ThreadWatcher(); export default threadWatcher; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index 3f8dffa89..15eea19aa 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,31 +1,31 @@ // @flow import bots from '../facts/bots'; import staff from '../facts/staff'; import type { RelativeMemberInfo } from '../types/thread-types'; import type { RelativeUserInfo } from '../types/user-types'; function stringForUser(user: RelativeUserInfo | RelativeMemberInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return user.username; } else { return 'anonymous'; } } function isStaff(userID: string) { if (staff.includes(userID)) { return true; } - for (let key in bots) { + for (const key in bots) { const bot = bots[key]; if (userID === bot.userID) { return true; } } return false; } export { stringForUser, isStaff }; diff --git a/lib/socket/inflight-requests.js b/lib/socket/inflight-requests.js index a6c891887..7d33e13e5 100644 --- a/lib/socket/inflight-requests.js +++ b/lib/socket/inflight-requests.js @@ -1,250 +1,250 @@ // @flow import invariant from 'invariant'; import { clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; import { type ServerSocketMessage, type StateSyncServerSocketMessage, type RequestsServerSocketMessage, type ActivityUpdateResponseServerSocketMessage, type PongServerSocketMessage, type APIResponseServerSocketMessage, type ServerSocketMessageType, serverSocketMessageTypes, } from '../types/socket-types'; import { ServerError, ExtendableError } from '../utils/errors'; import sleep from '../utils/sleep'; type ValidResponseMessageMap = { a: StateSyncServerSocketMessage, b: RequestsServerSocketMessage, c: ActivityUpdateResponseServerSocketMessage, d: PongServerSocketMessage, e: APIResponseServerSocketMessage, }; type BaseInflightRequest = {| expectedResponseType: $PropertyType, resolve: (response: Response) => void, reject: (error: Error) => void, messageID: number, |}; type InflightRequestMap = $ObjMap< ValidResponseMessageMap, (T) => BaseInflightRequest<$Exact>, >; type ValidResponseMessage = $Values; type InflightRequest = $Values; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; class SocketOffline extends ExtendableError {} class SocketTimeout extends ExtendableError { expectedResponseType: ServerSocketMessageType; constructor(expectedType: ServerSocketMessageType) { super(`socket timed out waiting for response type ${expectedType}`); this.expectedResponseType = expectedType; } } type Callbacks = {| timeout: () => void, setLateResponse: (messageID: number, isLate: boolean) => void, |}; class InflightRequests { data: InflightRequest[] = []; timeoutCallback: () => void; setLateResponse: (messageID: number, isLate: boolean) => void; constructor(callbacks: Callbacks) { this.timeoutCallback = callbacks.timeout; this.setLateResponse = callbacks.setLateResponse; } async fetchResponse( messageID: number, expectedType: $PropertyType, ): Promise { let inflightRequest: ?InflightRequest; const responsePromise = new Promise((resolve, reject) => { // Flow makes us do these unnecessary runtime checks... if (expectedType === serverSocketMessageTypes.STATE_SYNC) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.STATE_SYNC, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.REQUESTS) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.REQUESTS, resolve, reject, messageID, }; } else if ( expectedType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.PONG) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.PONG, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.API_RESPONSE) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.API_RESPONSE, resolve, reject, messageID, }; } }); invariant( inflightRequest, `${expectedType} is an invalid server response type`, ); this.data.push(inflightRequest); // We create this object so we can pass it by reference to the timeout // function below. That function will avoid setting this request as late if // the response has already arrived. const requestResult = { concluded: false, lateResponse: false }; try { const response = await Promise.race([ responsePromise, this.timeout(messageID, expectedType, requestResult), ]); requestResult.concluded = true; if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } this.clearRequest(inflightRequest); return response; } catch (e) { requestResult.concluded = true; this.clearRequest(inflightRequest); if (e instanceof SocketTimeout) { this.rejectAll(new Error('socket closed due to timeout')); this.timeoutCallback(); } else if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } throw e; } } async timeout( messageID: number, expectedType: ServerSocketMessageType, requestResult: {| concluded: boolean, lateResponse: boolean |}, ) { await sleep(clientRequestVisualTimeout); if (requestResult.concluded) { // We're just doing this to bail out. If requestResult.concluded we can // conclude that responsePromise already won the race. Returning here // gives Flow errors since Flow is worried response will be undefined. throw new Error(); } requestResult.lateResponse = true; this.setLateResponse(messageID, true); await sleep(remainingTimeAfterVisualTimeout); throw new SocketTimeout(expectedType); } clearRequest(requestToClear: InflightRequest) { this.data = this.data.filter((request) => request !== requestToClear); } resolveRequestsForMessage(message: ServerSocketMessage) { - for (let inflightRequest of this.data) { + for (const inflightRequest of this.data) { if ( message.responseTo === null || message.responseTo === undefined || inflightRequest.messageID !== message.responseTo ) { continue; } if (message.type === serverSocketMessageTypes.ERROR) { const error = message.payload ? new ServerError(message.message, message.payload) : new ServerError(message.message); inflightRequest.reject(error); } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { inflightRequest.reject(new SocketOffline('auth_error')); } else if ( message.type === serverSocketMessageTypes.STATE_SYNC && inflightRequest.expectedResponseType === serverSocketMessageTypes.STATE_SYNC ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.REQUESTS && inflightRequest.expectedResponseType === serverSocketMessageTypes.REQUESTS ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.PONG && inflightRequest.expectedResponseType === serverSocketMessageTypes.PONG ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.API_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.API_RESPONSE ) { inflightRequest.resolve(message); } } } rejectAll(error: Error) { const { data } = this; // Though the promise rejections below should call clearRequest when they're // caught in fetchResponse, that doesn't happen synchronously. Socket won't // close unless all requests are resolved, so we clear this.data immediately this.data = []; - for (let inflightRequest of data) { + for (const inflightRequest of data) { const { reject } = inflightRequest; reject(error); } } allRequestsResolvedExcept(excludeMessageID: ?number) { - for (let inflightRequest of this.data) { + for (const inflightRequest of this.data) { const { expectedResponseType } = inflightRequest; if ( expectedResponseType !== serverSocketMessageTypes.PONG && (excludeMessageID === null || excludeMessageID === undefined || excludeMessageID !== inflightRequest.messageID) ) { return false; } } return true; } } export { SocketOffline, SocketTimeout, InflightRequests }; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index c34333e8b..20b884450 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,727 +1,727 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { updateActivityActionTypes } from '../actions/activity-actions'; import { socketAuthErrorResolutionAttempt, logOutActionTypes, } from '../actions/user-actions'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; import type { LogOutResult } from '../types/account-types'; import type { CalendarQuery } from '../types/entry-types'; import type { Dispatch } from '../types/redux-types'; import { serverRequestTypes, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, type PreRequestUserState, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; import { actionLogger } from '../utils/action-logger'; import type { DispatchActionPromise } from '../utils/action-utils'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { ServerError } from '../utils/errors'; import { promiseAll } from '../utils/promises'; import sleep from '../utils/sleep'; import ActivityHandler from './activity-handler.react'; import APIRequestHandler from './api-request-handler.react'; import CalendarQueryHandler from './calendar-query-handler.react'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests'; import MessageHandler from './message-handler.react'; import ReportHandler from './report-handler.react'; import RequestResponseHandler from './request-response-handler.react'; import UpdateHandler from './update-handler.react'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = {| +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, |}; type Props = {| ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => WebSocket, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, |}; type State = {| +inflightRequests: ?InflightRequests, |}; class Socket extends React.PureComponent { state: State = { inflightRequests: null, }; socket: ?WebSocket; nextClientMessageID = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing = false; invalidationRecoveryInProgress = false; initializedWithUserState: ?PreRequestUserState; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect = _throttle(() => this.openSocket('reconnecting'), 2000); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render() { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID = (message: ClientSocketMessageWithoutID) => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage = async (event: MessageEvent) => { const message = Socket.messageFromEvent(event); if (!message) { return; } const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } - for (let listener of this.listeners) { + for (const listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, source: socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener = (listener: SocketListener) => { this.listeners.add(listener); }; removeListener = (listener: SocketListener) => { this.listeners.delete(listener); }; onClose = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, source: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket = async (retriesLeft: number = 1) => { try { await this.sendInitialMessage(); } catch (e) { console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse = (messageID: number, isLate: boolean) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground = (alreadyClosed: boolean) => { // On native, sometimes the app is backgrounded without the proper callbacks // getting triggered. This leaves us in an incorrect state for two reasons: // (1) The connection is still considered to be active, causing API requests // to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/lib/utils/action-logger.js b/lib/utils/action-logger.js index 30beb7fb9..6958ca510 100644 --- a/lib/utils/action-logger.js +++ b/lib/utils/action-logger.js @@ -1,180 +1,180 @@ // @flow import inspect from 'util-inspect'; import { saveDraftActionType } from '../actions/miscellaneous-action-types'; import { rehydrateActionType } from '../types/redux-types'; import type { ActionSummary } from '../types/report-types'; import { sanitizeAction } from './sanitization'; const uninterestingActionTypes = new Set([ saveDraftActionType, 'Navigation/COMPLETE_TRANSITION', ]); const maxActionSummaryLength = 500; type Subscriber = (action: Object, state: Object) => void; class ActionLogger { static n = 30; lastNActions = []; lastNStates = []; currentReduxState = undefined; currentOtherStates = {}; subscribers: Subscriber[] = []; get preloadedState(): Object { return this.lastNStates[0].state; } get actions(): Object[] { return this.lastNActions.map(({ action }) => action); } get interestingActionSummaries(): ActionSummary[] { return this.lastNActions .filter(({ action }) => !uninterestingActionTypes.has(action.type)) .map(({ action, time }) => ({ type: action.type, time, summary: ActionLogger.getSummaryForAction(action), })); } static getSummaryForAction(action: Object): string { const sanitized = sanitizeAction(action); let summary, length, depth = 3; do { summary = inspect(sanitized, { depth }); length = summary.length; depth--; } while (length > maxActionSummaryLength && depth > 0); return summary; } prepareForAction() { if ( this.lastNActions.length > 0 && this.lastNActions[this.lastNActions.length - 1].action.type === rehydrateActionType ) { // redux-persist can't handle replaying REHYDRATE // https://github.com/rt2zz/redux-persist/issues/743 this.lastNActions = []; this.lastNStates = []; } if (this.lastNActions.length === ActionLogger.n) { this.lastNActions.shift(); this.lastNStates.shift(); } } addReduxAction(action: Object, beforeState: Object, afterState: Object) { this.prepareForAction(); if (this.currentReduxState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, ...beforeState, }, }; } } this.currentReduxState = afterState; const state = { ...beforeState }; - for (let stateKey in this.currentOtherStates) { + for (const stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } addOtherAction( key: string, action: Object, beforeState: Object, afterState: Object, ) { this.prepareForAction(); const currentState = this.currentOtherStates[key]; if (currentState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, [key]: beforeState, }, }; } } this.currentOtherStates[key] = afterState; const state = { ...this.currentState, [key]: beforeState, }; const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } get mostRecentActionTime(): ?number { if (this.lastNActions.length === 0) { return null; } return this.lastNActions[this.lastNActions.length - 1].time; } get currentState(): Object { const state = this.currentReduxState ? { ...this.currentReduxState } : {}; - for (let stateKey in this.currentOtherStates) { + for (const stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } return state; } subscribe(subscriber: Subscriber) { this.subscribers.push(subscriber); } unsubscribe(subscriber: Subscriber) { this.subscribers = this.subscribers.filter( (candidate) => candidate !== subscriber, ); } triggerSubscribers(action: Object) { if (uninterestingActionTypes.has(action.type)) { return; } const state = this.currentState; this.subscribers.forEach((subscriber) => subscriber(action, state)); } } const actionLogger = new ActionLogger(); const reduxLoggerMiddleware = (store: *) => (next: *) => (action: *) => { const beforeState = store.getState(); const result = next(action); const afterState = store.getState(); actionLogger.addReduxAction(action, beforeState, afterState); return result; }; export { actionLogger, reduxLoggerMiddleware }; diff --git a/lib/utils/objects.js b/lib/utils/objects.js index cd2994358..988f527cd 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -1,51 +1,51 @@ // @flow import stableStringify from 'fast-json-stable-stringify'; import stringHash from 'string-hash'; function findMaximumDepth(obj: Object): ?{ path: string, depth: number } { let longestPath = null; let longestDepth = null; - for (let key in obj) { + for (const key in obj) { const value = obj[key]; if (typeof value !== 'object' || !value) { if (!longestDepth) { longestPath = key; longestDepth = 1; } continue; } const childResult = findMaximumDepth(obj[key]); if (!childResult) { continue; } const { path, depth } = childResult; const ourDepth = depth + 1; if (longestDepth === null || ourDepth > longestDepth) { longestPath = `${key}.${path}`; longestDepth = ourDepth; } } if (!longestPath || !longestDepth) { return null; } return { path: longestPath, depth: longestDepth }; } type Map = { [key: K]: T }; function values(map: Map): T[] { return Object.values ? // https://github.com/facebook/flow/issues/2221 // $FlowFixMe - Object.values currently does not have good flow support Object.values(map) : Object.keys(map).map((key: K): T => map[key]); } function hash(obj: ?Object): number { if (!obj) { return -1; } return stringHash(stableStringify(obj)); } export { findMaximumDepth, values, hash }; diff --git a/lib/utils/upload-blob.js b/lib/utils/upload-blob.js index 0916bde74..0cafab469 100644 --- a/lib/utils/upload-blob.js +++ b/lib/utils/upload-blob.js @@ -1,119 +1,119 @@ // @flow import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep'; import _throttle from 'lodash/throttle'; import { getConfig } from './config'; import type { FetchJSONOptions, FetchJSONServerResponse } from './fetch-json'; function uploadBlob( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise { const formData = new FormData(); if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. formData.append('cookie', cookie ? cookie : ''); } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. formData.append('sessionID', sessionID ? sessionID : ''); } - for (let key in input) { + for (const key in input) { if (key === 'multimedia' || key === 'cookie' || key === 'sessionID') { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); formData.append(key, value); } const { multimedia } = input; if (multimedia && Array.isArray(multimedia)) { - for (let media of multimedia) { + for (const media of multimedia) { // We perform an any-cast here because of React Native. Though Blob // support was introduced in react-native@0.54, it isn't compatible with // FormData. Instead, React Native requires a specific object format. formData.append('multimedia', (media: any)); } } const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.setRequestHeader('Accept', 'application/json'); if (options && options.timeout) { xhr.timeout = options.timeout; } if (options && options.onProgress) { const { onProgress } = options; xhr.upload.onprogress = _throttle( ({ loaded, total }) => onProgress(loaded / total), 50, ); } let failed = false; const responsePromise = new Promise((resolve, reject) => { xhr.onload = () => { if (failed) { return; } const text = xhr.responseText; try { resolve(_cloneDeep(JSON.parse(text))); } catch (e) { console.log(text); reject(e); } }; xhr.onabort = () => { failed = true; reject(new Error('request aborted')); }; xhr.onerror = (event) => { failed = true; reject(event); }; if (options && options.timeout) { xhr.ontimeout = (event) => { failed = true; reject(event); }; } if (options && options.abortHandler) { options.abortHandler(() => { failed = true; reject(new Error('request aborted')); xhr.abort(); }); } }); if (!failed) { xhr.send(formData); } return responsePromise; } export type UploadBlob = typeof uploadBlob; export { uploadBlob }; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index d20f86a2a..1ec28f90c 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1093 +1,1093 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _find from 'lodash/fp/find'; import _findIndex from 'lodash/fp/findIndex'; import _map from 'lodash/fp/map'; import _pickBy from 'lodash/fp/pickBy'; import _size from 'lodash/fp/size'; import _sum from 'lodash/fp/sum'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { View, Text, FlatList, AppState as NativeAppState, Platform, LayoutAnimation, TouchableWithoutFeedback, } from 'react-native'; import SafeAreaView from 'react-native-safe-area-view'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo, CalendarQuery, CalendarQueryUpdateResult, } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ConnectionStatus } from 'lib/types/socket-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; import sleep from 'lib/utils/sleep'; import ContentLoading from '../components/content-loading.react'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import type { KeyboardEvent } from '../keyboard/keyboard'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import DisconnectedBar from '../navigation/disconnected-bar.react'; import { createIsForegroundSelector, createActiveTabSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { calendarListData } from '../selectors/calendar-selectors'; import type { CalendarItem, SectionHeaderItem, SectionFooterItem, LoaderItem, } from '../selectors/calendar-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { useColors, useStyles, useIndicatorStyle, type Colors, type IndicatorStyle, } from '../themes/colors'; import type { ViewToken } from '../types/react-native'; import CalendarInputBar from './calendar-input-bar.react'; import { Entry, InternalEntry, dummyNodeForEntryHeightMeasurement, } from './entry.react'; import SectionFooter from './section-footer.react'; export type EntryInfoWithHeight = {| ...EntryInfo, +textHeight: number, |}; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | {| itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, |}; type ExtraData = {| +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, |}; const safeAreaViewForceInset = { top: 'always', bottom: 'never', }; type BaseProps = {| +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, |}; type Props = {| ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, |}; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?{ +remove: () => void }; keyboardDismissListener: ?{ +remove: () => void }; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { NativeAppState.addEventListener('change', this.handleAppStateChange); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { NativeAppState.removeEventListener('change', this.handleAppStateChange); if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate, } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); - let offset = currentScrollPosition + heightOfNewItems; + const offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } renderItem = (row: { item: CalendarItemWithHeight }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: CalendarItemWithHeight): number { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(Calendar.itemHeight)); } render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( (k) => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; - for (let token of info.viewableItems) { + for (const token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( (item) => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'listSeparator', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); export default React.memo(function ConnectedCalendar( props: BaseProps, ) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector((state) => state.navInfo.startDate); const endDate = useSelector((state) => state.navInfo.endDate); const calendarFilters = useSelector((state) => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); const connectionStatus = useSelector((state) => state.connection.status); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); return ( ); }); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 60f42ef89..e4bc8c44a 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,493 +1,493 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _sortBy from 'lodash/fp/sortBy'; import * as React from 'react'; import { View, Text, Alert } from 'react-native'; import { createSelector } from 'reselect'; import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type ThreadType, threadTypes, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; import { type AccountUserInfo } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import { SingleLine } from '../components/single-line.react'; import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import ThreadList from '../components/thread-list.react'; import ThreadVisibility from '../components/thread-visibility.react'; import UserList from '../components/user-list.react'; import type { NavigationRoute } from '../navigation/route-names'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| +threadType?: ThreadType, +parentThreadInfo?: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ComposeThread'>, +route: NavigationRoute<'ComposeThread'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +threadInfos: { [id: string]: ThreadInfo }, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +newThread: (request: NewThreadRequest) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class ComposeThread extends React.PureComponent { state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?BaseTagInput; createThreadPressed = false; waitingOnThreadID: ?string; componentDidMount() { this.setLinkButton(true); } setLinkButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } componentDidUpdate(prevProps: Props) { const oldReduxParentThreadInfo = prevProps.parentThreadInfo; const newReduxParentThreadInfo = this.props.parentThreadInfo; if ( newReduxParentThreadInfo && newReduxParentThreadInfo !== oldReduxParentThreadInfo ) { this.props.navigation.setParams({ parentThreadInfo: newReduxParentThreadInfo, }); } if ( this.waitingOnThreadID && this.props.threadInfos[this.waitingOnThreadID] && !prevProps.threadInfos[this.waitingOnThreadID] ) { const threadInfo = this.props.threadInfos[this.waitingOnThreadID]; this.props.navigation.pushNewThread(threadInfo); } } static getParentThreadInfo(props: { route: NavigationRoute<'ComposeThread'>, }): ?ThreadInfo { return props.route.params.parentThreadInfo; } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.route.params.threadType, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, threadType: ?ThreadType, ) => getPotentialMemberItems( text, userInfos, searchIndex, userInfoInputArray.map((userInfo) => userInfo.id), parentThreadInfo, threadType, ), ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } existingThreadsSelector = createSelector( (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( parentThreadInfo: ?ThreadInfo, threadInfos: { [id: string]: ThreadInfo }, userInfoInputArray: $ReadOnlyArray, ) => { const userIDs = userInfoInputArray.map((userInfo) => userInfo.id); if (userIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && (!parentThreadInfo || threadInfo.parentThreadID === parentThreadInfo.id) && userIDs.every((userID) => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, ); get existingThreads() { return this.existingThreadsSelector({ ...this.props, ...this.state }); } render() { let existingThreadsSection = null; const { existingThreads, userSearchResults } = this; if (existingThreads.length > 0) { existingThreadsSection = ( Existing threads ); } let parentThreadRow = null; const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); if (parentThreadInfo) { const threadType = this.props.route.params.threadType; invariant( threadType !== undefined && threadType !== null, `no threadType provided for ${parentThreadInfo.id}`, ); const threadVisibilityColor = this.props.colors.modalForegroundLabel; parentThreadRow = ( within {parentThreadInfo.uiName} ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressCreateThread, }; return ( {parentThreadRow} To: {existingThreadsSection} ); } tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { - for (let existingUserInfo of this.state.userInfoInputArray) { + for (const existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressCreateThread = () => { if (this.createThreadPressed) { return; } if (this.state.userInfoInputArray.length === 0) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a thread containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: this.dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { this.dispatchNewChatThreadAction(); } }; dispatchNewChatThreadAction = async () => { this.createThreadPressed = true; this.props.dispatchActionPromise( newThreadActionTypes, this.newChatThreadAction(), ); }; async newChatThreadAction() { this.setLinkButton(false); try { const threadTypeParam = this.props.route.params.threadType; const threadType = threadTypeParam ?? threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); invariant( threadType !== 5, 'Creating sidebars from thread composer is not yet supported', ); const result = await this.props.newThread({ type: threadType, parentThreadID: parentThreadInfo ? parentThreadInfo.id : null, initialMemberIDs, color: parentThreadInfo ? parentThreadInfo.color : null, }); this.waitingOnThreadID = result.newThreadID; return result; } catch (e) { this.createThreadPressed = false; this.setLinkButton(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState({ usernameInputText: '' }, this.onErrorAcknowledged); }; onSelectExistingThread = (threadID: string) => { const threadInfo = this.props.threadInfos[threadID]; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, parentThreadLabel: { color: 'modalSubtextLabel', fontSize: 16, paddingLeft: 6, }, parentThreadName: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 6, }, parentThreadRow: { alignItems: 'center', backgroundColor: 'modalSubtext', flexDirection: 'row', paddingLeft: 12, paddingVertical: 6, }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; export default React.memo(function ConnectedComposeThread( props: BaseProps, ) { const parentThreadInfoID = props.route.params.parentThreadInfo?.id; const reduxParentThreadInfo = useSelector((state) => parentThreadInfoID ? threadInfoSelector(state)[parentThreadInfoID] : null, ); const loadingStatus = useSelector( createLoadingStatusSelector(newThreadActionTypes), ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const threadInfos = useSelector(threadInfoSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return ( ); }); diff --git a/native/chat/inner-text-message.react.js b/native/chat/inner-text-message.react.js index 2768b1af3..9d0de1fb6 100644 --- a/native/chat/inner-text-message.react.js +++ b/native/chat/inner-text-message.react.js @@ -1,139 +1,139 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import { colorIsDark } from 'lib/shared/thread-utils'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import { KeyboardContext } from '../keyboard/keyboard-state'; import Markdown from '../markdown/markdown.react'; import { useSelector } from '../redux/redux-utils'; import { useColors, colors } from '../themes/colors'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { MessageListContext } from './message-list-types'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; function useTextMessageMarkdownRules(useDarkStyle: boolean) { const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); return messageListContext.getTextMessageMarkdownRules(useDarkStyle); } function dummyNodeForTextMessageHeightMeasurement(text: string) { return {text}; } type DummyTextNodeProps = {| ...React.ElementConfig, +children: string, |}; function DummyTextNode(props: DummyTextNodeProps) { const { children, style, ...rest } = props; const maxWidth = useSelector((state) => composedMessageMaxWidthSelector(state), ); const viewStyle = [props.style, styles.dummyMessage, { maxWidth }]; const rules = useTextMessageMarkdownRules(false); return ( {children} ); } type Props = {| +item: ChatTextMessageInfoItemWithHeight, +onPress: () => void, +messageRef?: (message: ?React.ElementRef) => void, |}; function InnerTextMessage(props: Props) { const { item } = props; const { text, creator } = item.messageInfo; const { isViewer } = creator; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const boundColors = useColors(); - let messageStyle = {}, - textStyle = {}, - darkColor; + const messageStyle = {}; + const textStyle = {}; + let darkColor; if (isViewer) { const threadColor = item.threadInfo.color; messageStyle.backgroundColor = `#${threadColor}`; darkColor = colorIsDark(threadColor); } else { messageStyle.backgroundColor = boundColors.listChatBubble; darkColor = activeTheme === 'dark'; } textStyle.color = darkColor ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const cornerStyle = getRoundedContainerStyle(filterCorners(allCorners, item)); if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct messageStyle.height = item.contentHeight; } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; const rules = useTextMessageMarkdownRules(darkColor); const message = ( {text} ); // We need to set onLayout in order to allow .measure() to be on the ref const onLayout = React.useCallback(() => {}, []); const { messageRef } = props; if (!messageRef) { return message; } return ( {message} ); } const styles = StyleSheet.create({ dummyMessage: { paddingHorizontal: 12, paddingVertical: 6, }, message: { overflow: 'hidden', paddingHorizontal: 12, paddingVertical: 6, }, text: { fontFamily: 'Arial', fontSize: 18, }, }); export { InnerTextMessage, dummyNodeForTextMessageHeightMeasurement }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 9101771b1..19fc21050 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,379 +1,379 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import { View, TouchableWithoutFeedback } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { messageKey } from 'lib/shared/message-utils'; import { useWatchThread } from 'lib/shared/thread-utils'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { ViewToken } from '../types/react-native'; import { ChatList } from './chat-list.react'; import type { ChatNavigationProp } from './chat.react'; import type { ChatMessageItemWithHeight } from './message-list-container.react'; import { Message, type ChatMessageInfoItemWithHeight } from './message.react'; import RelationshipPrompt from './relationship-prompt.react'; type BaseProps = {| +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, |}; type PropsAndState = {| ...Props, ...State, |}; type FlatListExtraData = {| messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, |}; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const newListData = this.props.messageListData; const oldListData = prevProps.messageListData; if ( this.state.loadingFromScroll && (newListData.length > oldListData.length || this.props.startReached) ) { this.setState({ loadingFromScroll: false }); } const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route, } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; - for (let token of info.viewableItems) { + for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.setState({ loadingFromScroll: true }); const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } }; oldestMessageServerID(): ?string { const data = this.props.messageListData; for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); export default React.memo(function ConnectedMessageList( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( (state) => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); useWatchThread(props.threadInfo); return ( ); }); diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js index 87b824389..593cbb691 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,337 +1,337 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import { messageKey } from 'lib/shared/message-utils'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils'; import { type MediaInfo } from 'lib/types/media-types'; import { type PendingMultimediaUpload } from '../input/input-state'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import { type NavigationRoute, VideoPlaybackModalRouteName, ImageModalRouteName, MultimediaTooltipModalRouteName, } from '../navigation/route-names'; import { type Colors, useColors } from '../themes/colors'; import { type VerticalBounds } from '../types/layout-types'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import InlineMultimedia from './inline-multimedia.react'; import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, sub, interpolate, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +mediaInfo: MediaInfo, +item: ChatMultimediaMessageInfoItem, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +verticalBounds: ?VerticalBounds, +verticalOffset: number, +style: ViewStyle, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +messageFocused: boolean, +toggleMessageFocus: (messageKey: string) => void, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, +canCreateSidebarFromMessage: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +opacity: number | Value, |}; class MultimediaMessageMultimedia extends React.PureComponent { view: ?React.ElementRef; clickable = true; constructor(props: Props) { super(props); this.state = { opacity: this.getOpacity(), }; } static getStableKey(props: Props) { const { item, mediaInfo } = props; return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant( overlayContext, 'MultimediaMessageMultimedia should have OverlayContext', ); return overlayContext; } static getModalOverlayPosition(props: Props) { const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props); const { visibleOverlays } = overlayContext; - for (let overlay of visibleOverlays) { + for (const overlay of visibleOverlays) { if ( overlay.routeName === ImageModalRouteName && overlay.presentedFrom === props.route.key && overlay.routeKey === MultimediaMessageMultimedia.getStableKey(props) ) { return overlay.position; } } return undefined; } getOpacity() { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); if (!overlayPosition) { return 1; } return sub( 1, interpolate(overlayPosition, { inputRange: [0.1, 0.11], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ); } componentDidUpdate(prevProps: Props) { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( prevProps, ); if (overlayPosition !== prevOverlayPosition) { this.setState({ opacity: this.getOpacity() }); } const scrollIsDisabled = MultimediaMessageMultimedia.getOverlayContext(this.props) .scrollBlockingModalStatus !== 'closed'; const scrollWasDisabled = MultimediaMessageMultimedia.getOverlayContext(prevProps) .scrollBlockingModalStatus !== 'closed'; if (!scrollIsDisabled && scrollWasDisabled) { this.clickable = true; } } render() { const { opacity } = this.state; const wrapperStyles = [styles.container, { opacity }, this.props.style]; const { mediaInfo, pendingUpload, postInProgress } = this.props; return ( ); } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); const { mediaInfo, item } = this.props; view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigation.navigate({ name: mediaInfo.type === 'video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: MultimediaMessageMultimedia.getStableKey(this.props), params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalBounds, }, }); }); }; visibleEntryIDs() { const result = ['save']; if (this.props.item.threadCreatedFromMessage) { result.push('open_sidebar'); } else if (this.props.canCreateSidebarFromMessage) { result.push('create_sidebar'); } return result; } onLongPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const { messageFocused, toggleMessageFocus, item, mediaInfo, verticalOffset, } = this.props; if (!messageFocused) { toggleMessageFocus(messageKey(item.messageInfo)); } const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = multimediaTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const directlyAboveMargin = isViewer ? 30 : 50; const aboveMargin = verticalOffset === 0 ? directlyAboveMargin : 20; const aboveSpace = multimediaTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: MultimediaTooltipModalRouteName, params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalOffset, verticalBounds, location, margin, visibleEntryIDs: this.visibleEntryIDs(), }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, expand: { flex: 1, }, }); export default React.memo( function ConnectedMultimediaMessageMultimedia(props: BaseProps) { const colors = useColors(); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); return ( ); }, ); diff --git a/native/chat/multimedia-message-send-failed.js b/native/chat/multimedia-message-send-failed.js index 2bc25ec3f..fc611e4f7 100644 --- a/native/chat/multimedia-message-send-failed.js +++ b/native/chat/multimedia-message-send-failed.js @@ -1,31 +1,31 @@ // @flow import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; export default function multimediaMessageSendFailed( item: ChatMultimediaMessageInfoItem, ): boolean { const { messageInfo, localMessageInfo, pendingUploads } = item; const { id: serverID } = messageInfo; if (serverID !== null && serverID !== undefined) { return false; } const { isViewer } = messageInfo.creator; if (!isViewer) { return false; } if (localMessageInfo && localMessageInfo.sendFailed) { return true; } - for (let media of messageInfo.media) { + for (const media of messageInfo.media) { const pendingUpload = pendingUploads && pendingUploads[media.id]; if (pendingUpload && pendingUpload.failed) { return true; } } return !pendingUploads; } diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 2c20475fe..c5de147b7 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,337 +1,337 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; import { createSelector } from 'reselect'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadActualMembers } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import { type AccountUserInfo } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import { createTagInput, BaseTagInput } from '../../components/tag-input.react'; import UserList from '../../components/user-list.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { useStyles } from '../../themes/colors'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| +presentedFrom: string, +threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +changeThreadSettingsLoadingStatus: LoadingStatus, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class AddUsersModal extends React.PureComponent { state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?BaseTagInput = null; userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => propsAndState.route.params.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { const excludeUserIDs = userInfoInputArray .map((userInfo) => userInfo.id) .concat(threadActualMembers(threadInfo.members)); return getPotentialMemberItems( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, threadInfo.type, ); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { let activityIndicator = null; if (this.props.changeThreadSettingsLoadingStatus === 'loading') { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { cancelButton = ( ); } else { cancelButton = ; } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {cancelButton} {addButton} ); } close = () => { this.props.navigation.goBackOnce(); }; tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } - for (let existingUserInfo of this.state.userInfoInputArray) { + for (const existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.addUsersToThread(), ); }; async addUsersToThread() { try { const newMemberIDs = this.state.userInfoInputArray.map( (userInfo) => userInfo.id, ); const result = await this.props.changeThreadSettings({ threadID: this.props.route.params.threadInfo.id, changes: { newMemberIDs }, }); this.close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'nameInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { userInfoInputArray: [], usernameInputText: '', }, this.onErrorAcknowledged, ); }; } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; export default React.memo(function ConnectedAddUsersModal( props: BaseProps, ) { const { parentThreadID } = props.route.params.threadInfo; const parentThreadInfo = useSelector((state) => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 6947332be..7c0a636b6 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,114 +1,114 @@ // @flow import invariant from 'invariant'; import { Alert } from 'react-native'; import { removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, } from 'lib/actions/thread-actions'; import { memberIsAdmin, roleIsAdminRole } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import { createTooltip, type TooltipParams, type TooltipRoute, } from '../../navigation/tooltip.react'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, |}>; function onRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => F, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = bindServerCall(removeUsersFromThread); const onConfirmRemoveUser = () => { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( removeUsersFromThreadActionTypes, boundRemoveUsersFromThread(threadInfo.id, [memberInfo.id]), { customKeyName }, ); }; const userText = stringForUser(memberInfo); Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } function onToggleAdmin( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => F, ) { const { memberInfo, threadInfo } = route.params; const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = bindServerCall(changeThreadMemberRoles); const onConfirmMakeAdmin = () => { let newRole = null; - for (let roleID in threadInfo.roles) { + for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( changeThreadMemberRolesActionTypes, boundChangeThreadMemberRoles(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); }; const userText = stringForUser(memberInfo); const actionClause = isCurrentlyAdmin ? `remove ${userText} as an admin` : `make ${userText} an admin`; Alert.alert( 'Confirm action', `Are you sure you want to ${actionClause} of this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmMakeAdmin }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'remove_user', text: 'Remove user', onPress: onRemoveUser }, { id: 'remove_admin', text: 'Remove admin', onPress: onToggleAdmin }, { id: 'make_admin', text: 'Make admin', onPress: onToggleAdmin }, ], }; const ThreadSettingsMemberTooltipModal = createTooltip< 'ThreadSettingsMemberTooltipModal', >(ThreadSettingsMemberTooltipButton, spec); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 4e649ed49..107a3cd02 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1147 +1,1147 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform } from 'react-native'; import { createSelector } from 'reselect'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; import type { RelationshipButton } from 'lib/types/relationship-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types'; import type { UserInfos } from 'lib/types/user-types'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubthreadModalRouteName, } from '../../navigation/route-names'; import type { AppState } from '../../redux/redux-setup'; import { useSelector } from '../../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; import type { ViewStyle } from '../../types/styles'; import type { ChatNavigationProp } from '../chat.react'; import type { CategoryType } from './thread-settings-category.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import ThreadSettingsChildThread from './thread-settings-child-thread.react'; import ThreadSettingsColor from './thread-settings-color.react'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; import ThreadSettingsDescription from './thread-settings-description.react'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubthread, } from './thread-settings-list-action.react'; import ThreadSettingsMember from './thread-settings-member.react'; import ThreadSettingsName from './thread-settings-name.react'; import ThreadSettingsParent from './thread-settings-parent.react'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = {| +threadInfo: ThreadInfo, |}; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | {| +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, |} | {| +itemType: 'footer', +key: string, +categoryType: CategoryType, |} | {| +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, |} | {| +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |} | {| +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +parentThreadInfo: ?ThreadInfo, +navigate: ThreadSettingsNavigate, |} | {| +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'seeMore', +key: string, +onPress: () => void, |} | {| +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |} | {| +itemType: 'addSubthread', +key: string, |} | {| +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |} | {| +itemType: 'addMember', +key: string, |} | {| +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, |} | {| +itemType: 'editRelationship', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, |}; type Props = {| ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ?ThreadInfo, +parentThreadInfo: ?ThreadInfo, +threadMembers: $ReadOnlyArray, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +numMembersShowing: number, +numSubthreadsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); const threadInfo = props.threadInfo; invariant(threadInfo, 'ThreadInfo should exist when ThreadSettings opened'); this.state = { numMembersShowing: itemPageLength, numSubthreadsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: threadInfo.color, verticalBounds: null, }; } static getThreadInfo(props: { threadInfo: ?ThreadInfo, route: NavigationRoute<'ThreadSettings'>, ... }): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidMount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (this.props.navigation.isFocused() && !this.props.somethingIsSaving) { this.props.navigation.popToTop(); } }; componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldNavThreadInfo = ThreadSettings.getThreadInfo(prevProps); const newNavThreadInfo = ThreadSettings.getThreadInfo(this.props); if (oldNavThreadInfo.id !== newNavThreadInfo.id) { if (!threadInChatList(oldNavThreadInfo)) { threadWatcher.removeID(oldNavThreadInfo.id); } if (!threadInChatList(newNavThreadInfo)) { threadWatcher.watchID(newNavThreadInfo.id); } } if ( newNavThreadInfo.color !== oldNavThreadInfo.color && this.state.colorEditValue === oldNavThreadInfo.color ) { this.setState({ colorEditValue: newNavThreadInfo.color }); } const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThread = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD, ); const canChangeSettings = canEditThread && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThread ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, navigate, }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subthreadsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubthreadsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubthreadsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subthreads = childThreads?.filter( (childThreadInfo) => childThreadInfo.type !== threadTypes.SIDEBAR, ) ?? []; const canCreateSubthreads = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBTHREADS, ); if (subthreads.length === 0 && !canCreateSubthreads) { return listData; } listData.push({ itemType: 'header', key: 'subthreadHeader', title: 'Subthreads', categoryType: 'unpadded', }); if (canCreateSubthreads) { listData.push({ itemType: 'addSubthread', key: 'addSubthread', }); } const numItems = Math.min(numSubthreadsShowing, subthreads.length); for (let i = 0; i < numItems; i++) { const subthreadInfo = subthreads[i]; listData.push({ itemType: 'childThread', key: `childThread${subthreadInfo.id}`, threadInfo: subthreadInfo, navigate, firstListItem: i === 0 && !canCreateSubthreads, lastListItem: i === numItems - 1 && numItems === subthreads.length, }); } if (numItems < subthreads.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubthreads', onPress: this.onPressSeeMoreSubthreads, }); } listData.push({ itemType: 'footer', key: 'subthreadFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( (childThreadInfo) => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Sidebars', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, navigate, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.threadMembers, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, threadMembers: $ReadOnlyArray, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadMembers.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadMembers.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadMembers[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadMembers.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadMembers.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; const canChangeThreadType = threadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canCreateSubthreadsInParent = threadHasPermission( parentThreadInfo, threadPermissions.CREATE_SUBTHREADS, ); const canPromoteSidebar = threadInfo.type === threadTypes.SIDEBAR && canChangeThreadType && canCreateSubthreadsInParent; if (canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons( otherUserInfo, ); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subthreadsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subthreadsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subthreadsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubthread') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubthread = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(ComposeSubthreadModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressAddMember = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState((prevState) => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubthreads = () => { this.setState((prevState) => ({ numSubthreadsShowing: prevState.numSubthreadsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState((prevState) => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } - for (let threadMember of threadMembers) { + for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; export default React.memo(function ConnectedThreadSettings( props: BaseProps, ) { const userInfos = useSelector((state) => state.userStore.userInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const threadInfo: ?ThreadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const parentThreadID = threadInfo ? threadInfo.parentThreadID : props.route.params.threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector((state) => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); const boundChildThreadInfos = useSelector( (state) => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector((state) => somethingIsSaving(state, threadMembers), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); }); diff --git a/native/chat/thread-screen-pruner.react.js b/native/chat/thread-screen-pruner.react.js index 23a9fc149..f5596c2b1 100644 --- a/native/chat/thread-screen-pruner.react.js +++ b/native/chat/thread-screen-pruner.react.js @@ -1,83 +1,83 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import { threadIsPending } from 'lib/shared/thread-utils'; import { clearThreadsActionType } from '../navigation/action-types'; import { useActiveThread } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, } from '../navigation/navigation-utils'; import type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; const ThreadScreenPruner = React.memo<{||}>(() => { const rawThreadInfos = useSelector( (state: AppState) => state.threadStore.threadInfos, ); const navContext = React.useContext(NavContext); const chatRoute = React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; const appState = getStateFromNavigatorRoute(state.routes[0]); const tabState = getStateFromNavigatorRoute(appState.routes[0]); return getStateFromNavigatorRoute(tabState.routes[1]); }, [navContext]); const inStackThreadIDs = React.useMemo(() => { const threadIDs = new Set(); if (!chatRoute) { return threadIDs; } - for (let route of chatRoute.routes) { + for (const route of chatRoute.routes) { const threadID = getThreadIDFromRoute(route); if (threadID && !threadIsPending(threadID)) { threadIDs.add(threadID); } } return threadIDs; }, [chatRoute]); const pruneThreadIDs = React.useMemo(() => { const threadIDs = []; - for (let threadID of inStackThreadIDs) { + for (const threadID of inStackThreadIDs) { if (!rawThreadInfos[threadID]) { threadIDs.push(threadID); } } return threadIDs; }, [inStackThreadIDs, rawThreadInfos]); const activeThreadID = useActiveThread(); React.useEffect(() => { if (pruneThreadIDs.length === 0 || !navContext) { return; } if (activeThreadID && pruneThreadIDs.includes(activeThreadID)) { Alert.alert( 'Thread invalidated', 'You no longer have permission to view this thread :(', [{ text: 'OK' }], { cancelable: true }, ); } navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: pruneThreadIDs }, }); }, [pruneThreadIDs, navContext, activeThreadID]); return null; }); ThreadScreenPruner.displayName = 'ThreadScreenPruner'; export default ThreadScreenPruner; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index c3665b9b8..2d85578f2 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1140 +1,1140 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import * as Upload from 'react-native-background-upload'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { pathFromURI } from 'lib/media/file-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import { isStaff } from 'lib/shared/user-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types'; import type { RawImagesMessageInfo } from 'lib/types/messages/images'; import type { RawMediaMessageInfo } from 'lib/types/messages/media'; import type { RawTextMessageInfo } from 'lib/types/messages/text'; import type { Dispatch } from 'lib/types/redux-types'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; import { disposeTempFile } from '../media/file-utils'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { useSelector } from '../redux/redux-utils'; import { InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state'; let nextLocalUploadID = 0; function getNewLocalID() { return `localUpload${nextLocalUploadID++}`; } type SelectionWithID = {| +selection: NativeMediaSelection, +localID: string, |}; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = {| +children: React.Node, |}; type Props = {| ...BaseProps, +viewerID: ?string, +nextLocalID: number, +messageStoreMessages: { [id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, +sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| +pendingUploads: PendingMultimediaUploads, |}; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; - for (let localMessageID in state.pendingUploads) { + for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); const completed = []; let allUploadsComplete = true; - for (let localUploadID in messagePendingUploads) { + for (const localUploadID in messagePendingUploads) { let media; - for (let singleMedia of rawMessageInfo.media) { + for (const singleMedia of rawMessageInfo.media) { if (singleMedia.id === localUploadID) { media = singleMedia; break; } } if (media) { allUploadsComplete = false; } else { completed.push(localUploadID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completed.length > 0) { completedUploads[localMessageID] = new Set(completed); } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; - for (let localMessageID in this.state.pendingUploads) { + for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; - for (let localUploadID in messagePendingUploads) { + for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } - for (let localMessageID of readyMessageIDs) { + for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; - for (let { id } of messageInfo.media) { + for (const { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, addReply: this.addReply, addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } - for (let localMessageID in this.state.pendingUploads) { + for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; - for (let localUploadID in messagePendingUploads) { + for (const localUploadID in messagePendingUploads) { const { failed } = messagePendingUploads[localUploadID]; if (!failed) { return true; } } } return false; }; sendTextMessage = (messageInfo: RawTextMessageInfo) => { this.sendCallbacks.forEach((callback) => callback()); this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); }; async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } sendMultimediaMessage = async ( threadID: string, selections: $ReadOnlyArray, ) => { this.sendCallbacks.forEach((callback) => callback()); const localMessageID = `local${this.props.nextLocalID}`; const selectionsWithIDs = selections.map((selection) => ({ selection, localID: getNewLocalID(), })); const pendingUploads = {}; - for (let { localID } of selectionsWithIDs) { + for (const { localID } of selectionsWithIDs) { pendingUploads[localID] = { failed: null, progressPercent: 0, }; } this.setState( (prevState) => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = selectionsWithIDs.map(({ localID, selection }) => { if (selection.step === 'photo_library') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_capture') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_paste') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'video_library') { return { id: localID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, }; } invariant(false, `invalid selection ${JSON.stringify(selection)}`); }); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID, creatorID, media, }); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, selectionsWithIDs); }; async uploadFiles( localMessageID: string, selectionsWithIDs: $ReadOnlyArray, ) { const results = await Promise.all( selectionsWithIDs.map((selectionWithID) => this.uploadFile(localMessageID, selectionWithID), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, selectionWithID: SelectionWithID, ): Promise { const { localID, selection } = selectionWithID; const start = selection.sendTime; - let steps = [selection], - serverID, - userTime, - errorMessage; + const steps = [selection]; + let serverID; + let userTime; + let errorMessage; let reportPromise; const finish = async (result: MediaMissionResult) => { if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const fail = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localID, message); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(localMessageID, localID), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; fail(message); return await finish(processResult); } processedMedia = processResult; } catch (e) { fail('processing failed'); return await finish({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, mediaMissionResult; try { uploadResult = await this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.loop }, { onProgress: (percent: number) => this.setProgress(localMessageID, localID, 'uploading', percent), uploadBlob: this.uploadBlob, }, ); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); fail('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if (uploadResult) { const { id, mediaType, uri, dimensions, loop } = uploadResult; serverID = id; this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: localMessageID, currentMediaID: localID, mediaUpdate: { id, type: mediaType, uri, dimensions, localMediaSelection: undefined, loop, }, }, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const promises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete promises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose const captureURI = selection.uri; promises.push( (async () => { const { steps: clearSteps, result: capturePath, } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(promises); return await finish(mediaMissionResult); } mediaProcessConfig(localMessageID: string, localID: string) { const { hasWiFi, viewerID } = this.props; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localID, 'transcoding', percent); }; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, onTranscodingProgress, }; } return { hasWiFi, onTranscodingProgress }; } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; - for (let key in input) { + for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, (data) => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, (data) => { try { resolve(JSON.parse(data.responseBody)); } catch (e) { reject(e); } }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, (data) => onProgress(data.progress / 100), ); } }); }; handleUploadFailure( localMessageID: string, localUploadID: string, message: string, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: message, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: {| localID: string, localMessageID: string, serverID: ?string |}, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } - for (let localUploadID in pendingUploads) { + for (const localUploadID in pendingUploads) { const { failed } = pendingUploads[localUploadID]; if (failed) { return true; } } return false; }; addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; retryTextMessage = (rawMessageInfo: RawTextMessageInfo) => { this.sendTextMessage({ ...rawMessageInfo, time: Date.now(), }); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, ) => { let pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { pendingUploads = {}; } const now = Date.now(); const updateMedia = (media: $ReadOnlyArray): T[] => media.map((singleMedia) => { const oldID = singleMedia.id; if (!oldID.startsWith('localUpload')) { // already uploaded return singleMedia; } if (pendingUploads[oldID] && !pendingUploads[oldID].failed) { // still being uploaded return singleMedia; } // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const id = pendingUploads[oldID] ? oldID : getNewLocalID(); const oldSelection = singleMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (singleMedia.type === 'photo') { return { type: 'photo', ...singleMedia, id, localMediaSelection: selection, }; } else { return { type: 'video', ...singleMedia, id, localMediaSelection: selection, }; } }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; - for (let singleMedia of newRawMessageInfo.media) { + for (const singleMedia of newRawMessageInfo.media) { if (singleMedia.id.startsWith('localUpload')) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages - for (let { id } of retryMedia) { + for (const { id } of retryMedia) { pendingUploads[id] = { failed: null, progressPercent: 0, processingStep: null, }; } this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const selectionsWithIDs = retryMedia.map((singleMedia) => { const { id, localMediaSelection } = singleMedia; invariant( localMediaSelection, 'localMediaSelection should be set on locally created Media', ); return { selection: localMediaSelection, localID: id }; }); await this.uploadFiles(localMessageID, selectionsWithIDs); }; retryMessage = async (localMessageID: string) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { this.retryTextMessage(rawMessageInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage(rawMessageInfo, localMessageID); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( (candidate) => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); - for (let callback of onClear) { + for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise((resolve) => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); export default React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector((state) => state.nextLocalID); const messageStoreMessages = useSelector( (state) => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( (state) => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector((state) => state.connectivity.hasWiFi); const callUploadMultimedia = useServerCall(uploadMultimedia); const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); const callSendTextMessage = useServerCall(sendTextMessage); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); return ( ); }); diff --git a/native/media/ffmpeg.js b/native/media/ffmpeg.js index 27cb82539..d3d086a53 100644 --- a/native/media/ffmpeg.js +++ b/native/media/ffmpeg.js @@ -1,154 +1,154 @@ // @flow import { RNFFmpeg, RNFFprobe, RNFFmpegConfig } from 'react-native-ffmpeg'; import { getHasMultipleFramesProbeCommand } from 'lib/media/video-utils'; import type { FFmpegStatistics } from 'lib/types/media-types'; const maxSimultaneousCalls = { process: 1, probe: 1, }; type CallCounter = typeof maxSimultaneousCalls; type QueuedCommandType = $Keys; type QueuedCommand = {| type: QueuedCommandType, runCommand: () => Promise, |}; class FFmpeg { queue: QueuedCommand[] = []; currentCalls: CallCounter = { process: 0, probe: 0 }; queueCommand( type: QueuedCommandType, wrappedCommand: () => Promise, ): Promise { return new Promise((resolve, reject) => { const runCommand = async () => { try { const result = await wrappedCommand(); this.currentCalls[type]--; this.possiblyRunCommands(); resolve(result); } catch (e) { reject(e); } }; this.queue.push({ type, runCommand }); this.possiblyRunCommands(); }); } possiblyRunCommands() { let openSlots = {}; - for (let type in this.currentCalls) { + for (const type in this.currentCalls) { const currentCalls = this.currentCalls[type]; const maxCalls = maxSimultaneousCalls[type]; const callsLeft = maxCalls - currentCalls; if (!callsLeft) { return; } else if (currentCalls) { openSlots = { [type]: callsLeft }; break; } else { openSlots[type] = callsLeft; } } const toDefer = [], toRun = []; - for (let command of this.queue) { + for (const command of this.queue) { const type: string = command.type; if (openSlots[type]) { openSlots = { [type]: openSlots[type] - 1 }; this.currentCalls[type]++; toRun.push(command); } else { toDefer.push(command); } } this.queue = toDefer; toRun.forEach(({ runCommand }) => runCommand()); } transcodeVideo( ffmpegCommand: string, inputVideoDuration: number, onTranscodingProgress: (percent: number) => void, ) { const duration = inputVideoDuration > 0 ? inputVideoDuration : 0.001; const wrappedCommand = async () => { RNFFmpegConfig.resetStatistics(); let lastStats; RNFFmpegConfig.enableStatisticsCallback( (statisticsData: FFmpegStatistics) => { lastStats = statisticsData; const { time } = statisticsData; onTranscodingProgress(time / 1000 / duration); }, ); const ffmpegResult = await RNFFmpeg.execute(ffmpegCommand); return { ...ffmpegResult, lastStats }; }; return this.queueCommand('process', wrappedCommand); } generateThumbnail(videoPath: string, outputPath: string) { const wrappedCommand = () => FFmpeg.innerGenerateThumbnail(videoPath, outputPath); return this.queueCommand('process', wrappedCommand); } static async innerGenerateThumbnail(videoPath: string, outputPath: string) { const thumbnailCommand = `-i ${videoPath} -frames 1 -f singlejpeg ${outputPath}`; const { rc } = await RNFFmpeg.execute(thumbnailCommand); return rc; } getVideoInfo(path: string) { const wrappedCommand = () => FFmpeg.innerGetVideoInfo(path); return this.queueCommand('probe', wrappedCommand); } static async innerGetVideoInfo(path: string) { const info = await RNFFprobe.getMediaInformation(path); const videoStreamInfo = FFmpeg.getVideoStreamInfo(info); const codec = videoStreamInfo && videoStreamInfo.codec; const dimensions = videoStreamInfo && videoStreamInfo.dimensions; const format = info.format.split(','); const duration = info.duration / 1000; return { codec, format, dimensions, duration }; } static getVideoStreamInfo(info: Object) { if (!info.streams) { return null; } - for (let stream of info.streams) { + for (const stream of info.streams) { if (stream.type === 'video') { const { codec, width, height } = stream; return { codec, dimensions: { width, height } }; } } return null; } hasMultipleFrames(path: string) { const wrappedCommand = () => FFmpeg.innerHasMultipleFrames(path); return this.queueCommand('probe', wrappedCommand); } static async innerHasMultipleFrames(path: string) { await RNFFprobe.execute(getHasMultipleFramesProbeCommand(path)); const probeOutput = await RNFFmpegConfig.getLastCommandOutput(); const numFrames = parseInt(probeOutput.lastCommandOutput); return numFrames > 1; } } const ffmpeg = new FFmpeg(); export { ffmpeg }; diff --git a/native/media/media-gallery-keyboard.react.js b/native/media/media-gallery-keyboard.react.js index 49c299810..d968623a0 100644 --- a/native/media/media-gallery-keyboard.react.js +++ b/native/media/media-gallery-keyboard.react.js @@ -1,558 +1,558 @@ // @flow import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, ActivityIndicator, Animated, Easing, Platform, } from 'react-native'; import { KeyboardRegistry } from 'react-native-keyboard-input'; import { Provider } from 'react-redux'; import { extensionFromFilename } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import type { MediaLibrarySelection } from 'lib/types/media-types'; import type { DimensionsInfo } from '../redux/dimensions-updater.react'; import { store } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { ViewToken, LayoutEvent } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { getCompatibleMediaURI } from './identifier-utils'; import MediaGalleryMedia from './media-gallery-media.react'; import SendMediaButton from './send-media-button.react'; const animationSpec = { duration: 400, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; type Props = {| // Redux state +dimensions: DimensionsInfo, +foreground: boolean, +colors: Colors, +styles: typeof unboundStyles, |}; type State = {| +selections: ?$ReadOnlyArray, +error: ?string, +containerHeight: ?number, // null means end reached; undefined means no fetch yet +cursor: ?string, +queuedMediaURIs: ?Set, +focusedMediaURI: ?string, +dimensions: DimensionsInfo, |}; class MediaGalleryKeyboard extends React.PureComponent { mounted = false; fetchingPhotos = false; flatList: ?FlatList; viewableIndices: number[] = []; queueModeProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; mediaSelected = false; constructor(props: Props) { super(props); const sendButtonScale = this.queueModeProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.3, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.queueModeProgress, transform: [{ scale: sendButtonScale }], }; this.state = { selections: null, error: null, containerHeight: null, cursor: undefined, queuedMediaURIs: null, focusedMediaURI: null, dimensions: props.dimensions, }; } static getDerivedStateFromProps(props: Props) { // We keep this in state since we pass this.state as // FlatList's extraData prop return { dimensions: props.dimensions }; } componentDidMount() { this.mounted = true; return this.fetchPhotos(); } componentWillUnmount() { this.mounted = false; } componentDidUpdate(prevProps: Props, prevState: State) { const { queuedMediaURIs } = this.state; const prevQueuedMediaURIs = prevState.queuedMediaURIs; if (queuedMediaURIs && !prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 1, }).start(); } else if (!queuedMediaURIs && prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 0, }).start(); } const { flatList, viewableIndices } = this; const { selections, focusedMediaURI } = this.state; let scrollingSomewhere = false; if (flatList && selections) { let newURI; if (focusedMediaURI && focusedMediaURI !== prevState.focusedMediaURI) { newURI = focusedMediaURI; } else if ( queuedMediaURIs && (!prevQueuedMediaURIs || queuedMediaURIs.size > prevQueuedMediaURIs.size) ) { const flowMadeMeDoThis = queuedMediaURIs; - for (let queuedMediaURI of flowMadeMeDoThis) { + for (const queuedMediaURI of flowMadeMeDoThis) { if (prevQueuedMediaURIs && prevQueuedMediaURIs.has(queuedMediaURI)) { continue; } newURI = queuedMediaURI; break; } } let index; if (newURI !== null && newURI !== undefined) { index = selections.findIndex(({ uri }) => uri === newURI); } if (index !== null && index !== undefined) { if (index === viewableIndices[0]) { scrollingSomewhere = true; flatList.scrollToIndex({ index }); } else if (index === viewableIndices[viewableIndices.length - 1]) { scrollingSomewhere = true; flatList.scrollToIndex({ index, viewPosition: 1 }); } } } if (this.props.foreground && !prevProps.foreground) { this.fetchPhotos(); } if ( !scrollingSomewhere && this.flatList && this.state.selections && prevState.selections && this.state.selections.length > 0 && prevState.selections.length > 0 && this.state.selections[0].uri !== prevState.selections[0].uri ) { this.flatList.scrollToIndex({ index: 0 }); } } guardedSetState(change) { if (this.mounted) { this.setState(change); } } async fetchPhotos(after?: ?string) { if (this.fetchingPhotos) { return; } this.fetchingPhotos = true; try { const hasPermission = await this.getPermissions(); if (!hasPermission) { return; } const { assets, endCursor, hasNextPage, } = await MediaLibrary.getAssetsAsync({ first: 20, after, mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], sortBy: [MediaLibrary.SortBy.modificationTime], }); let firstRemoved = false, lastRemoved = false; const mediaURIs = this.state.selections ? this.state.selections.map(({ uri }) => uri) : []; const existingURIs = new Set(mediaURIs); let first = true; const selections = assets .map((asset) => { const { id, height, width, filename, mediaType, duration } = asset; const isVideo = mediaType === MediaLibrary.MediaType.video; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (existingURIs.has(uri)) { if (first) { firstRemoved = true; } lastRemoved = true; first = false; return null; } first = false; lastRemoved = false; existingURIs.add(uri); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, selectTime: 0, sendTime: 0, retries: 0, }; } }) .filter(Boolean); let appendOrPrepend = after ? 'append' : 'prepend'; if (firstRemoved && !lastRemoved) { appendOrPrepend = 'append'; } else if (!firstRemoved && lastRemoved) { appendOrPrepend = 'prepend'; } let newSelections = selections; if (this.state.selections) { if (appendOrPrepend === 'prepend') { newSelections = [...newSelections, ...this.state.selections]; } else { newSelections = [...this.state.selections, ...newSelections]; } } this.guardedSetState({ selections: newSelections, error: null, cursor: hasNextPage ? endCursor : null, }); } catch (e) { this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } this.fetchingPhotos = false; } async getPermissions(): Promise { const { granted } = await MediaLibrary.requestPermissionsAsync(); if (!granted) { this.guardedSetState({ error: "don't have permission :(" }); } return granted; } get queueModeActive() { return !!this.state.queuedMediaURIs; } renderItem = (row: { item: MediaLibrarySelection }) => { const { containerHeight, queuedMediaURIs } = this.state; invariant(containerHeight, 'should be set'); const { uri } = row.item; const isQueued = !!(queuedMediaURIs && queuedMediaURIs.has(uri)); const { queueModeActive } = this; return ( ); }; ItemSeparator = () => { return ; }; static keyExtractor(item: MediaLibrarySelection) { return item.uri; } render() { let content; const { selections, error, containerHeight } = this.state; const bottomOffsetStyle: ViewStyle = { marginBottom: this.props.dimensions.bottomInset, }; if (selections && selections.length > 0 && containerHeight) { content = ( ); } else if (selections && containerHeight) { content = ( no media was found! ); } else if (error) { content = ( {error} ); } else { content = ( ); } const { queuedMediaURIs } = this.state; const queueCount = queuedMediaURIs ? queuedMediaURIs.size : 0; const bottomInset = Platform.select({ ios: -1 * this.props.dimensions.bottomInset, default: 0, }); const containerStyle = { bottom: bottomInset }; return ( {content} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ containerHeight: event.nativeEvent.layout.height }); }; onEndReached = () => { const { cursor } = this.state; if (cursor !== null) { this.fetchPhotos(cursor); } }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { const viewableIndices = []; - for (let { index } of info.viewableItems) { + for (const { index } of info.viewableItems) { if (index !== null && index !== undefined) { viewableIndices.push(index); } } this.viewableIndices = viewableIndices; }; setMediaQueued = (selection: MediaLibrarySelection, isQueued: boolean) => { this.setState((prevState: State) => { const prevQueuedMediaURIs = prevState.queuedMediaURIs ? [...prevState.queuedMediaURIs] : []; if (isQueued) { return { queuedMediaURIs: new Set([...prevQueuedMediaURIs, selection.uri]), focusedMediaURI: null, }; } const queuedMediaURIs = prevQueuedMediaURIs.filter( (uri) => uri !== selection.uri, ); if (queuedMediaURIs.length < prevQueuedMediaURIs.length) { return { queuedMediaURIs: new Set(queuedMediaURIs), focusedMediaURI: null, }; } return null; }); }; setFocus = (selection: MediaLibrarySelection, isFocused: boolean) => { const { uri } = selection; if (isFocused) { this.setState({ focusedMediaURI: uri }); } else if (this.state.focusedMediaURI === uri) { this.setState({ focusedMediaURI: null }); } }; sendSingleMedia = (selection: MediaLibrarySelection) => { this.sendMedia([selection]); }; sendQueuedMedia = () => { const { selections, queuedMediaURIs } = this.state; if (!selections || !queuedMediaURIs) { return; } const queuedSelections = []; - for (let uri of queuedMediaURIs) { - for (let selection of selections) { + for (const uri of queuedMediaURIs) { + for (const selection of selections) { if (selection.uri === uri) { queuedSelections.push(selection); break; } } } this.sendMedia(queuedSelections); }; sendMedia(selections: $ReadOnlyArray) { if (this.mediaSelected) { return; } this.mediaSelected = true; const now = Date.now(); const timeProps = { selectTime: now, sendTime: now, }; const selectionsWithTime = selections.map((selection) => ({ ...selection, ...timeProps, })); KeyboardRegistry.onItemSelected( mediaGalleryKeyboardName, selectionsWithTime, ); } } const mediaGalleryKeyboardName = 'MediaGalleryKeyboard'; const unboundStyles = { container: { alignItems: 'center', backgroundColor: 'listBackground', flexDirection: 'row', left: 0, position: 'absolute', right: 0, top: 0, }, error: { color: 'listBackgroundLabel', flex: 1, fontSize: 28, textAlign: 'center', }, loadingIndicator: { flex: 1, }, sendButtonContainer: { bottom: 20, position: 'absolute', right: 30, }, separator: { width: 2, }, }; function ConnectedMediaGalleryKeyboard() { const dimensions = useSelector((state) => state.dimensions); const foreground = useIsAppForegrounded(); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } function ReduxMediaGalleryKeyboard() { return ( ); } KeyboardRegistry.registerKeyboard( mediaGalleryKeyboardName, () => ReduxMediaGalleryKeyboard, ); export { mediaGalleryKeyboardName }; diff --git a/native/more/dev-tools.react.js b/native/more/dev-tools.react.js index b6a1fcb4a..742480e54 100644 --- a/native/more/dev-tools.react.js +++ b/native/more/dev-tools.react.js @@ -1,246 +1,246 @@ // @flow import * as React from 'react'; import { View, Text, ScrollView, Platform } from 'react-native'; import ExitApp from 'react-native-exit-app'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types'; import { setURLPrefix } from 'lib/utils/url-utils'; import Button from '../components/button.react'; import type { NavigationRoute } from '../navigation/route-names'; import { CustomServerModalRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useColors, useStyles, type Colors } from '../themes/colors'; import { wipeAndExit } from '../utils/crash-utils'; import { serverOptions } from '../utils/url-utils'; import type { MoreNavigationProp } from './more.react'; const ServerIcon = () => ( ); type BaseProps = {| +navigation: MoreNavigationProp<'DevTools'>, +route: NavigationRoute<'DevTools'>, |}; type Props = {| ...BaseProps, +urlPrefix: string, +customServer: ?string, +colors: Colors, +styles: typeof unboundStyles, +dispatch: Dispatch, |}; class DevTools extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const serverButtons = []; - for (let server of serverOptions) { + for (const server of serverOptions) { const icon = server === this.props.urlPrefix ? : null; serverButtons.push( , ); serverButtons.push( , ); } const customServerLabel = this.props.customServer ? ( {'custom: '} {this.props.customServer} ) : ( custom ); const customServerIcon = this.props.customServer === this.props.urlPrefix ? : null; serverButtons.push( , ); return ( SERVER {serverButtons} ); } onPressCrash = () => { throw new Error('User triggered crash through dev menu!'); }; onPressKill = () => { ExitApp.exitApp(); }; onPressWipe = async () => { await wipeAndExit(); }; onSelectServer = (server: string) => { if (server !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: server, }); } }; onSelectCustomServer = () => { this.props.navigation.navigate(CustomServerModalRouteName, { presentedFrom: this.props.route.key, }); }; } const unboundStyles = { container: { flex: 1, }, customServerLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, redText: { color: 'redText', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, serverContainer: { flex: 1, }, serverText: { color: 'panelForegroundLabel', fontSize: 16, }, slightlyPaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; export default React.memo(function ConnectedDevTools( props: BaseProps, ) { const urlPrefix = useSelector((state) => state.urlPrefix); const customServer = useSelector((state) => state.customServer); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }); diff --git a/native/navigation/modal-pruner.react.js b/native/navigation/modal-pruner.react.js index ce093de59..b3087cca6 100644 --- a/native/navigation/modal-pruner.react.js +++ b/native/navigation/modal-pruner.react.js @@ -1,135 +1,135 @@ // @flow import type { PossiblyStaleNavigationState, PossiblyStaleRoute, } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { clearRootModalsActionType, clearOverlayModalsActionType, } from './action-types'; import type { NavContextType } from './navigation-context'; import { AppRouteName } from './route-names'; type DependencyInfo = {| status: 'missing' | 'resolved' | 'unresolved', presenter: ?string, presenting: string[], parentRouteName: ?string, |}; function collectDependencyInfo( route: PossiblyStaleNavigationState | PossiblyStaleRoute<>, dependencyMap?: Map = new Map(), parentRouteName?: ?string, ): Map { let state, routeName; if (route.name === undefined) { state = route; } else if (route.state) { ({ state, name: routeName } = route); } if (state) { - for (let child of state.routes) { + for (const child of state.routes) { collectDependencyInfo(child, dependencyMap, routeName); } return dependencyMap; } if (!route.key) { return dependencyMap; } const { key } = route; const presenter = route.params && route.params.presentedFrom ? route.params.presentedFrom : null; invariant( presenter === null || typeof presenter === 'string', 'presentedFrom should be a string', ); let status = 'resolved'; if (presenter) { const presenterInfo = dependencyMap.get(presenter); if (!presenterInfo) { status = 'unresolved'; dependencyMap.set(presenter, { status: 'missing', presenter: undefined, presenting: [key], parentRouteName: undefined, }); } else if (presenterInfo) { status = presenterInfo.status; presenterInfo.presenting.push(key); } } const existingInfo = dependencyMap.get(key); const presenting = existingInfo ? existingInfo.presenting : []; dependencyMap.set(key, { status, presenter, presenting, parentRouteName, }); if (status === 'resolved') { const toResolve = [...presenting]; while (toResolve.length > 0) { const presentee = toResolve.pop(); const dependencyInfo = dependencyMap.get(presentee); invariant(dependencyInfo, 'could not find presentee'); dependencyInfo.status = 'resolved'; toResolve.push(...dependencyInfo.presenting); } } return dependencyMap; } type Props = {| navContext: NavContextType, |}; function ModalPruner(props: Props) { const { state, dispatch } = props.navContext; const [pruneRootModals, pruneOverlayModals] = React.useMemo(() => { const dependencyMap = collectDependencyInfo(state); const rootModals = [], overlayModals = []; - for (let [key, info] of dependencyMap) { + for (const [key, info] of dependencyMap) { if (info.status !== 'unresolved') { continue; } if (!info.parentRouteName) { rootModals.push(key); } else if (info.parentRouteName === AppRouteName) { overlayModals.push(key); } } return [rootModals, overlayModals]; }, [state]); React.useEffect(() => { if (pruneRootModals.length > 0) { dispatch({ type: (clearRootModalsActionType: 'CLEAR_ROOT_MODALS'), payload: { keys: pruneRootModals }, }); } if (pruneOverlayModals.length > 0) { dispatch({ type: (clearOverlayModalsActionType: 'CLEAR_OVERLAY_MODALS'), payload: { keys: pruneOverlayModals }, }); } }, [dispatch, pruneRootModals, pruneOverlayModals]); return null; } export default ModalPruner; diff --git a/native/navigation/overlay-navigator.react.js b/native/navigation/overlay-navigator.react.js index 8d783bdf4..fcb7df22a 100644 --- a/native/navigation/overlay-navigator.react.js +++ b/native/navigation/overlay-navigator.react.js @@ -1,470 +1,470 @@ // @flow import type { StackNavigationState, NavigatorPropsBase, ExtraNavigatorPropsBase, } from '@react-navigation/native'; import { useNavigationBuilder, createNavigatorFactory, NavigationHelpersContext, } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated, { Easing } from 'react-native-reanimated'; import { values } from 'lib/utils/objects'; import { OverlayContext } from './overlay-context'; import OverlayRouter from './overlay-router'; import type { OverlayRouterNavigationProp } from './overlay-router'; import { scrollBlockingModals, TabNavigatorRouteName } from './route-names'; /* eslint-disable import/no-named-as-default-member */ const { Value, timing, cond, call, lessOrEq, block } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = $Exact>>; const OverlayNavigator = React.memo( ({ initialRouteName, children, screenOptions }: Props) => { const { state, descriptors, navigation } = useNavigationBuilder( OverlayRouter, { children, screenOptions, initialRouteName, }, ); const curIndex = state.index; const positionRefs = React.useRef({}); const positions = positionRefs.current; const firstRenderRef = React.useRef(true); React.useEffect(() => { firstRenderRef.current = false; }, [firstRenderRef]); const firstRender = firstRenderRef.current; const { routes } = state; const scenes = React.useMemo( () => routes.map((route, routeIndex) => { const descriptor = descriptors[route.key]; invariant( descriptor, `OverlayNavigator could not find descriptor for ${route.key}`, ); if (!positions[route.key]) { positions[route.key] = new Value(firstRender ? 1 : 0); } return { route, descriptor, context: { position: positions[route.key], isDismissing: curIndex < routeIndex, }, ordering: { routeIndex, }, }; }), // We don't include descriptors here because they can change on every // render. We know that they should only substantially change if something // about the underlying route has changed // eslint-disable-next-line react-hooks/exhaustive-deps [positions, routes, curIndex], ); const prevScenesRef = React.useRef(); const prevScenes = prevScenesRef.current; const visibleOverlayEntryForNewScene = (scene) => { const { route } = scene; if (route.name === TabNavigatorRouteName) { // We don't consider the TabNavigator at the bottom to be an overlay return undefined; } const presentedFrom = route.params ? route.params.presentedFrom : undefined; return { routeKey: route.key, routeName: route.name, position: positions[route.key], presentedFrom, }; }; const visibleOverlaysRef = React.useRef(); if (!visibleOverlaysRef.current) { visibleOverlaysRef.current = scenes .map(visibleOverlayEntryForNewScene) .filter(Boolean); } let visibleOverlays = visibleOverlaysRef.current; // The scrollBlockingModalStatus state gets incorporated into the // OverlayContext, but it's global to the navigator rather than local to // each screen. Note that we also include the setter in OverlayContext. We // do this so that screens can freeze ScrollViews as quickly as possible to // avoid drags after onLongPress is triggered const getScrollBlockingModalStatus = (data) => { let status = 'closed'; - for (let scene of data) { + for (const scene of data) { if (!scrollBlockingModals.includes(scene.route.name)) { continue; } if (!scene.context.isDismissing) { status = 'open'; break; } status = 'closing'; } return status; }; const [ scrollBlockingModalStatus, setScrollBlockingModalStatus, ] = React.useState(() => getScrollBlockingModalStatus(scenes)); const sceneDataForNewScene = (scene) => ({ ...scene, context: { ...scene.context, visibleOverlays, scrollBlockingModalStatus, setScrollBlockingModalStatus, }, ordering: { ...scene.ordering, creationTime: Date.now(), }, listeners: [], }); // We track two previous states of scrollBlockingModalStatus via refs. We // need two because we expose setScrollBlockingModalStatus to screens. We // track the previous sceneData-determined value separately so that we only // overwrite the screen-determined value with the sceneData-determined value // when the latter actually changes const prevScrollBlockingModalStatusRef = React.useRef( scrollBlockingModalStatus, ); const prevScrollBlockingModalStatus = prevScrollBlockingModalStatusRef.current; const prevScrollBlockingModalStatusFromSceneDataRef = React.useRef( scrollBlockingModalStatus, ); const prevScrollBlockingModalStatusFromSceneData = prevScrollBlockingModalStatusFromSceneDataRef.current; // We need state to continue rendering screens while they are dismissing const [sceneData, setSceneData] = React.useState(() => { const newSceneData = {}; - for (let scene of scenes) { + for (const scene of scenes) { const { key } = scene.route; newSceneData[key] = sceneDataForNewScene(scene); } return newSceneData; }); const prevSceneDataRef = React.useRef(sceneData); const prevSceneData = prevSceneDataRef.current; // We need to initiate animations in useEffect blocks, but because we // setState within render we might have multiple renders before the // useEffect triggers. So we cache whether or not new animations should be // started in this ref const pendingAnimationsRef = React.useRef<{ [key: string]: number }>({}); const queueAnimation = (key: string, toValue: number) => { pendingAnimationsRef.current = { ...pendingAnimationsRef.current, [key]: toValue, }; }; // This block keeps sceneData updated when our props change. It's the // hook equivalent of getDerivedStateFromProps // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops const updatedSceneData = { ...sceneData }; let sceneDataChanged = false; if (prevScenes && scenes !== prevScenes) { const currentKeys = new Set(); - for (let scene of scenes) { + for (const scene of scenes) { const { key } = scene.route; currentKeys.add(key); let data = updatedSceneData[key]; if (!data) { // A new route has been pushed const newVisibleOverlayEntry = visibleOverlayEntryForNewScene(scene); if (newVisibleOverlayEntry) { visibleOverlays = [...visibleOverlays, newVisibleOverlayEntry]; } updatedSceneData[key] = sceneDataForNewScene(scene); sceneDataChanged = true; queueAnimation(key, 1); continue; } let dataChanged = false; if (scene.route !== data.route) { data = { ...data, route: scene.route }; dataChanged = true; } if (scene.descriptor !== data.descriptor) { data = { ...data, descriptor: scene.descriptor }; // We don't set dataChanged here because descriptors get recomputed on // every render, which means we could get an infinite loop. However, // we want to update the descriptor whenever anything else changes, so // that if and when our scene is dismissed, the sceneData has the most // recent descriptor } if (scene.context.isDismissing !== data.context.isDismissing) { data = { ...data, context: { ...data.context, ...scene.context } }; dataChanged = true; } if (scene.ordering.routeIndex !== data.ordering.routeIndex) { data = { ...data, ordering: { ...data.ordering, ...scene.ordering } }; dataChanged = true; } if (dataChanged) { // Something about an existing route has changed updatedSceneData[key] = data; sceneDataChanged = true; } } for (let i = 0; i < prevScenes.length; i++) { const scene = prevScenes[i]; const { key } = scene.route; if (currentKeys.has(key)) { continue; } currentKeys.add(key); const data = updatedSceneData[key]; invariant(data, `should have sceneData for dismissed key ${key}`); // A route just got dismissed // We'll watch the animation to determine when to clear the screen const { position } = data.context; invariant(position, `should have position for dismissed key ${key}`); updatedSceneData[key] = { ...data, context: { ...data.context, isDismissing: true, }, listeners: [ cond( lessOrEq(position, 0), call([], () => { // This gets called when the scene is no longer visible and // handles cleaning up our data structures to remove it const curVisibleOverlays = visibleOverlaysRef.current; invariant( curVisibleOverlays, 'visibleOverlaysRef should be set', ); const newVisibleOverlays = curVisibleOverlays.filter( (overlay) => overlay.routeKey !== key, ); invariant( newVisibleOverlays.length < curVisibleOverlays.length, `could not find ${key} in visibleOverlays`, ); visibleOverlaysRef.current = newVisibleOverlays; setSceneData((curSceneData) => { const newSceneData = {}; - for (let sceneKey in curSceneData) { + for (const sceneKey in curSceneData) { if (sceneKey === key) { continue; } newSceneData[sceneKey] = { ...curSceneData[sceneKey], context: { ...curSceneData[sceneKey].context, visibleOverlays: newVisibleOverlays, }, }; } return newSceneData; }); }), ), ], }; sceneDataChanged = true; queueAnimation(key, 0); } } if (visibleOverlays !== visibleOverlaysRef.current) { // This indicates we have pushed a new route. Let's make sure every // sceneData has the updated visibleOverlays - for (let sceneKey in updatedSceneData) { + for (const sceneKey in updatedSceneData) { updatedSceneData[sceneKey] = { ...updatedSceneData[sceneKey], context: { ...updatedSceneData[sceneKey].context, visibleOverlays, }, }; } visibleOverlaysRef.current = visibleOverlays; sceneDataChanged = true; } const pendingAnimations = pendingAnimationsRef.current; React.useEffect(() => { if (Object.keys(pendingAnimations).length === 0) { return; } - for (let key in pendingAnimations) { + for (const key in pendingAnimations) { const toValue = pendingAnimations[key]; const position = positions[key]; invariant(position, `should have position for animating key ${key}`); timing(position, { duration: 150, easing: Easing.inOut(Easing.ease), toValue, }).start(); } pendingAnimationsRef.current = {}; }, [positions, pendingAnimations]); // If sceneData changes, we update scrollBlockingModalStatus based on it, both // in state and within the individual sceneData contexts. If sceneData doesn't // change, it's still possible for scrollBlockingModalStatus to change via the // setScrollBlockingModalStatus callback we expose via context let newScrollBlockingModalStatus; if (sceneDataChanged || sceneData !== prevSceneData) { const statusFromSceneData = getScrollBlockingModalStatus( values(updatedSceneData), ); if ( statusFromSceneData !== scrollBlockingModalStatus && statusFromSceneData !== prevScrollBlockingModalStatusFromSceneData ) { newScrollBlockingModalStatus = statusFromSceneData; } prevScrollBlockingModalStatusFromSceneDataRef.current = statusFromSceneData; } if ( !newScrollBlockingModalStatus && scrollBlockingModalStatus !== prevScrollBlockingModalStatus ) { newScrollBlockingModalStatus = scrollBlockingModalStatus; } if (newScrollBlockingModalStatus) { if (newScrollBlockingModalStatus !== scrollBlockingModalStatus) { setScrollBlockingModalStatus(newScrollBlockingModalStatus); } - for (let key in updatedSceneData) { + for (const key in updatedSceneData) { const data = updatedSceneData[key]; updatedSceneData[key] = { ...data, context: { ...data.context, scrollBlockingModalStatus: newScrollBlockingModalStatus, }, }; } sceneDataChanged = true; } if (sceneDataChanged) { setSceneData(updatedSceneData); } // Usually this would be done in an effect, but calling setState from the body // of a hook causes the hook to rerender before triggering effects. To avoid // infinite loops we make sure to set our prev values after we finish // comparing them prevScenesRef.current = scenes; prevSceneDataRef.current = sceneDataChanged ? updatedSceneData : sceneData; prevScrollBlockingModalStatusRef.current = newScrollBlockingModalStatus ? newScrollBlockingModalStatus : scrollBlockingModalStatus; const sceneList = values(updatedSceneData).sort((a, b) => { const routeIndexDifference = a.ordering.routeIndex - b.ordering.routeIndex; if (routeIndexDifference) { return routeIndexDifference; } return a.ordering.creationTime - b.ordering.creationTime; }); const screens = []; let pressableSceneAssigned = false, activeSceneFound = false; for (let i = sceneList.length - 1; i >= 0; i--) { const scene = sceneList[i]; const { route, descriptor, context, listeners } = scene; if (!context.isDismissing) { activeSceneFound = true; } let pressable = false; if ( !pressableSceneAssigned && activeSceneFound && (!route.params || !route.params.preventPresses) ) { // Only one route can be pressable at a time. We pick the first route that // is not dismissing and doesn't have preventPresses set in its params pressable = true; pressableSceneAssigned = true; } const { render } = descriptor; const pointerEvents = pressable ? 'auto' : 'none'; // These listeners are used to clear routes after they finish dismissing const listenerCode = listeners.length > 0 ? : null; screens.unshift( {render()} {listenerCode} , ); } return ( {screens} ); }, ); OverlayNavigator.displayName = 'OverlayNavigator'; const createOverlayNavigator = createNavigatorFactory< StackNavigationState, {||}, {||}, OverlayRouterNavigationProp<>, ExtraNavigatorPropsBase, >(OverlayNavigator); const styles = StyleSheet.create({ container: { flex: 1, }, scene: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); export { createOverlayNavigator }; diff --git a/native/push/android.js b/native/push/android.js index 3697a7895..5eae03f1c 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,102 +1,103 @@ // @flow import invariant from 'invariant'; import type { RemoteMessage } from 'react-native-firebase'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; import { recordAndroidNotificationActionType, rescindAndroidNotificationActionType, } from '../redux/action-types'; import { store, dispatch } from '../redux/redux-setup'; import { getFirebase } from './firebase'; import { saveMessageInfos } from './utils'; const androidNotificationChannelID = 'default'; const vibrationSpec = [500, 500]; function handleAndroidMessage( message: RemoteMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: {| body: string, title: ?string |}, ) => boolean, ) { const firebase = getFirebase(); const { data } = message; const { badge } = data; if (badge !== undefined && badge !== null) { firebase.notifications().setBadge(parseInt(badge, 10)); } const { messageInfos } = data; if (messageInfos) { saveMessageInfos(messageInfos, updatesCurrentAsOf); } const { rescind, rescindID } = data; if (rescind) { invariant(rescindID, 'rescind message without notifID'); firebase .notifications() .android.removeDeliveredNotificationsByTag(rescindID); dispatch({ type: rescindAndroidNotificationActionType, payload: { notifID: rescindID, threadID: data.threadID }, }); return; } - let { id, title, prefix, body, threadID, badgeOnly } = data; + const { id, title, prefix, threadID, badgeOnly } = data; + let { body } = data; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } if (badgeOnly === '1') { return; } const notification = new firebase.notifications.Notification() .setNotificationId(id) .setBody(body) .setData({ threadID }) .android.setTag(id) .android.setChannelId(androidNotificationChannelID) .android.setDefaults([firebase.notifications.Android.Defaults.All]) .android.setVibrate(vibrationSpec) .android.setAutoCancel(true) .android.setLargeIcon('@mipmap/ic_launcher') .android.setSmallIcon('@drawable/notif_icon'); if (title) { notification.setTitle(title); } firebase.notifications().displayNotification(notification); // We keep track of what notifs have been rendered for a given thread so // that we can clear them immediately (without waiting for the rescind) // when the user navigates to that thread. Since we can't do this while // the app is closed, we rely on the rescind notif in that case. dispatch({ type: recordAndroidNotificationActionType, payload: { threadID, notifID: id }, }); } async function androidBackgroundMessageTask(message: RemoteMessage) { const { updatesCurrentAsOf } = store.getState(); handleAndroidMessage(message, updatesCurrentAsOf); } export { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, }; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index da6db5d43..54c030226 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,628 +1,628 @@ // @flow import * as React from 'react'; import { AppRegistry, Platform, Alert, Vibration, LogBox } from 'react-native'; import type { RemoteMessage, NotificationOpen } from 'react-native-firebase'; import { Notification as InAppNotification, TapticFeedback, } from 'react-native-in-app-message'; import NotificationsIOS from 'react-native-notifications'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ConnectionInfo } from 'lib/types/socket-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle'; import { replaceWithThreadActionType } from '../navigation/action-types'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import { recordNotifPermissionAlertActionType, clearAndroidNotificationsActionType, } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { RootContext, type RootContextType } from '../root-context'; import { type GlobalTheme } from '../types/themes'; import { type NotifPermissionAlertInfo } from './alerts'; import { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, } from './android'; import { getFirebase } from './firebase'; import InAppNotif from './in-app-notif.react'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, } from './ios'; import { saveMessageInfos } from './utils'; LogBox.ignoreLogs([ // react-native-firebase 'Require cycle: ../node_modules/react-native-firebase', // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); const msInDay = 24 * 60 * 60 * 1000; const supportsTapticFeedback = Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 10; type BaseProps = {| +navigation: RootNavigationProp<'App'>, |}; type Props = {| ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceToken: ?string, +threadInfos: { [id: string]: ThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: (deviceToken: string) => Promise, // withRootContext +rootContext: ?RootContextType, |}; type State = {| +inAppNotifProps: ?{| +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, |}, |}; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidTokenListener: ?() => void = null; androidMessageListener: ?() => void = null; androidNotifOpenListener: ?() => void = null; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?{ +remove: () => void }; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { NotificationsIOS.addEventListener( 'remoteNotificationsRegistered', this.registerPushPermissions, ); NotificationsIOS.addEventListener( 'remoteNotificationsRegistrationFailed', this.failedToRegisterPushPermissions, ); NotificationsIOS.addEventListener( 'notificationReceivedForeground', this.iosForegroundNotificationReceived, ); NotificationsIOS.addEventListener( 'notificationOpened', this.iosNotificationOpened, ); } else if (Platform.OS === 'android') { const firebase = getFirebase(); const channel = new firebase.notifications.Android.Channel( androidNotificationChannelID, 'Default', firebase.notifications.Android.Importance.Max, ).setDescription('SquadCal notifications channel'); firebase.notifications().android.createChannel(channel); this.androidTokenListener = firebase .messaging() .onTokenRefresh(this.handleAndroidDeviceToken); this.androidMessageListener = firebase .messaging() .onMessage(this.androidMessageReceived); this.androidNotifOpenListener = firebase .notifications() .onNotificationOpened(this.androidNotificationOpened); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { NotificationsIOS.removeEventListener( 'remoteNotificationsRegistered', this.registerPushPermissions, ); NotificationsIOS.removeEventListener( 'remoteNotificationsRegistrationFailed', this.failedToRegisterPushPermissions, ); NotificationsIOS.removeEventListener( 'notificationReceivedForeground', this.iosForegroundNotificationReceived, ); NotificationsIOS.removeEventListener( 'notificationOpened', this.iosNotificationOpened, ); } else if (Platform.OS === 'android') { if (this.androidTokenListener) { this.androidTokenListener(); this.androidTokenListener = null; } if (this.androidMessageListener) { this.androidMessageListener(); this.androidMessageListener = null; } if (this.androidNotifOpenListener) { this.androidNotifOpenListener(); this.androidNotifOpenListener = null; } } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else if (this.props.deviceToken) { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for this.setDeviceToken(this.props.deviceToken); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } - for (let threadID of this.openThreadOnceReceived) { + for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if ( (this.props.loggedIn && !prevProps.loggedIn) || (!this.props.deviceToken && prevProps.deviceToken) ) { this.ensurePushNotifsEnabled(); } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { if (supportsTapticFeedback) { TapticFeedback.impact(); } else { Vibration.vibrate(400); } InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { NotificationsIOS.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { getFirebase().notifications().setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { NotificationsIOS.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { getFirebase().notifications().removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { NotificationsIOS.getDeliveredNotifications((notifications) => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { this.props.dispatch({ type: clearAndroidNotificationsActionType, payload: { threadID: activeThread }, }); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: Object[], ) { const identifiersToClear = []; - for (let notification of notifications) { + for (const notification of notifications) { if (notification['thread-id'] === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { NotificationsIOS.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { const missingDeviceToken = this.props.deviceToken === null || this.props.deviceToken === undefined; await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const firebase = getFirebase(); const hasPermission = await firebase.messaging().hasPermission(); if (!hasPermission) { try { await firebase.messaging().requestPermission(); } catch { this.failedToRegisterPushPermissions(); return; } } const fcmToken = await firebase.messaging().getToken(); if (fcmToken) { await this.handleAndroidDeviceToken(fcmToken); } else { this.failedToRegisterPushPermissions(); } } handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotif = await getFirebase() .notifications() .getInitialNotification(); if (initialNotif) { await this.androidNotificationOpened(initialNotif); } } registerPushPermissions = (deviceToken: string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } if (deviceToken !== this.props.deviceToken) { this.setDeviceToken(deviceToken); } }; setDeviceToken(deviceToken: string) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceToken), undefined, deviceToken, ); } failedToRegisterPushPermissions = () => { if (!this.props.loggedIn) { return; } const deviceType = Platform.OS; if (deviceType === 'ios') { iosPushPermissionResponseReceived(); if (__DEV__) { // iOS simulator can't handle notifs return; } } const alertInfo = this.props.notifPermissionAlertInfo; if ( (alertInfo.totalAlerts > 3 && alertInfo.lastAlertTime > Date.now() - msInDay) || (alertInfo.totalAlerts > 6 && alertInfo.lastAlertTime > Date.now() - msInDay * 3) || (alertInfo.totalAlerts > 9 && alertInfo.lastAlertTime > Date.now() - msInDay * 7) ) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); if (deviceType === 'ios') { Alert.alert( 'Need notif permissions', 'SquadCal needs notification permissions to keep you in the loop! ' + 'Please enable in Settings App -> Notifications -> SquadCal.', [{ text: 'OK' }], ); } else if (deviceType === 'android') { Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } }; navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigation.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { threadInfo }, }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(messageInfosString: string) { saveMessageInfos(messageInfosString, this.props.updatesCurrentAsOf); } iosForegroundNotificationReceived = (notification) => { if ( notification.getData() && notification.getData().managedAps && notification.getData().managedAps.action === 'CLEAR' ) { notification.finish(NotificationsIOS.FetchResult.NoData); return; } if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish(NotificationsIOS.FetchResult.NoData); return; } const threadID = notification.getData().threadID; if (!threadID) { console.log('Notification with missing threadID received!'); notification.finish(NotificationsIOS.FetchResult.NoData); return; } const messageInfos = notification.getData().messageInfos; if (messageInfos) { this.saveMessageInfos(messageInfos); } let title = null; let body = notification.getMessage(); if (notification.getData().title) { ({ title, body } = mergePrefixIntoBody(notification.getData())); } this.showInAppNotification(threadID, body, title); notification.finish(NotificationsIOS.FetchResult.NewData); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (notification) => { this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; if (!threadID) { console.log('Notification with missing threadID received!'); notification.finish(NotificationsIOS.FetchResult.NoData); return; } const messageInfos = notification.getData().messageInfos; if (messageInfos) { this.saveMessageInfos(messageInfos); } this.onPressNotificationForThread(threadID, true); notification.finish(NotificationsIOS.FetchResult.NewData); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (notificationOpen: NotificationOpen) => { this.onPushNotifBootsApp(); const { threadID } = notificationOpen.notification.data; this.onPressNotificationForThread(threadID, true); }; androidMessageReceived = async (message: RemoteMessage) => { this.onPushNotifBootsApp(); handleAndroidMessage( message, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: {| body: string, title: ?string |}, ) => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render() { return ( ); } } AppRegistry.registerHeadlessTask( 'RNFirebaseBackgroundMessage', () => androidBackgroundMessageTask, ); export default React.memo(function ConnectedPushHandler( props: BaseProps, ) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceToken = useSelector((state) => state.deviceToken); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( (state) => state.notifPermissionAlertInfo, ); const connection = useSelector((state) => state.connection); const updatesCurrentAsOf = useSelector((state) => state.updatesCurrentAsOf); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); }); diff --git a/native/redux/dimensions-updater.react.js b/native/redux/dimensions-updater.react.js index 0c293b258..31fd002d6 100644 --- a/native/redux/dimensions-updater.react.js +++ b/native/redux/dimensions-updater.react.js @@ -1,112 +1,112 @@ // @flow import * as React from 'react'; import { initialWindowMetrics, useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context'; import { useDispatch } from 'react-redux'; import type { Dimensions } from 'lib/types/media-types'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, androidKeyboardResizesFrame, rnsacThinksAndroidKeyboardResizesFrame, } from '../keyboard/keyboard'; import { updateDimensionsActiveType } from './action-types'; import { useSelector } from './redux-utils'; type BaseDimensionsInfo = {| ...Dimensions, +topInset: number, +bottomInset: number, |}; export type DimensionsInfo = {| ...BaseDimensionsInfo, +tabBarHeight: number, +rotated: boolean, |}; type Metrics = {| +frame: {| +x: number, +y: number, +width: number, +height: number |}, +insets: {| +top: number, +left: number, +right: number, +bottom: number |}, |}; function dimensionsUpdateFromMetrics(metrics: ?Metrics): BaseDimensionsInfo { if (!metrics) { // This happens when the app gets booted to run a background task return { height: 0, width: 0, topInset: 0, bottomInset: 0 }; } return { height: metrics.frame.height, width: metrics.frame.width, topInset: androidKeyboardResizesFrame ? 0 : metrics.insets.top, bottomInset: androidKeyboardResizesFrame ? 0 : metrics.insets.bottom, }; } const defaultDimensionsInfo = { ...dimensionsUpdateFromMetrics(initialWindowMetrics), tabBarHeight: 50, rotated: false, }; const defaultIsPortrait = defaultDimensionsInfo.height >= defaultDimensionsInfo.width; function DimensionsUpdater() { const dimensions = useSelector((state) => state.dimensions); const dispatch = useDispatch(); const frame = useSafeAreaFrame(); const insets = useSafeAreaInsets(); const keyboardShowingRef = React.useRef(); const keyboardShow = React.useCallback(() => { keyboardShowingRef.current = true; }, []); const keyboardDismiss = React.useCallback(() => { keyboardShowingRef.current = false; }, []); React.useEffect(() => { if (!rnsacThinksAndroidKeyboardResizesFrame) { return; } const showListener = addKeyboardShowListener(keyboardShow); const dismissListener = addKeyboardDismissListener(keyboardDismiss); return () => { removeKeyboardListener(showListener); removeKeyboardListener(dismissListener); }; }, [keyboardShow, keyboardDismiss]); const keyboardShowing = keyboardShowingRef.current; React.useEffect(() => { if (keyboardShowing) { return; } let updates = dimensionsUpdateFromMetrics({ frame, insets }); if (updates.height && updates.width && updates.height !== updates.width) { updates = { ...updates, rotated: updates.width > updates.height === defaultIsPortrait, }; } - for (let key in updates) { + for (const key in updates) { if (updates[key] === dimensions[key]) { continue; } dispatch({ type: updateDimensionsActiveType, payload: updates, }); return; } }, [keyboardShowing, dimensions, dispatch, frame, insets]); return null; } export { defaultDimensionsInfo, DimensionsUpdater }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 255f5853d..f1b03cbb6 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,439 +1,439 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import type { Orientations } from 'react-native-orientation-locker'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import type { PersistState } from 'redux-persist/src/types'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { type EntryStore } from 'lib/types/entry-types'; import { type CalendarFilter, defaultCalendarFilters, } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { type ConnectionInfo, defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { type NavInfo, defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { type NotifPermissionAlertInfo, defaultNotifPermissionAlertInfo, } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import reduceDrafts from '../reducers/draft-reducer'; import { type DeviceCameraInfo, defaultDeviceCameraInfo, } from '../types/camera'; import { type ConnectivityInfo, defaultConnectivityInfo, } from '../types/connectivity'; import { type GlobalThemeInfo, defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natServer, setCustomServer, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, } from './action-types'; import { defaultDimensionsInfo, type DimensionsInfo, } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, drafts: { [key: string]: string }, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, |}; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, inconsistencyReports: [], }, threadStore: { threadInfos: {}, inconsistencyReports: [], }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, drafts: {}, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix(), customServer: natServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', nextLocalID: 0, queuedReports: [], _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: *) { if (action.type === setReduxStateActionType) { return action.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.source, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.source, )) ) { return state; } if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return { ...state, threadIDsToNotifIDs: reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ), }; } else if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.started) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; - for (let update of action.payload.updatesResult.newUpdates) { + for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } state = { ...baseReducer(state, action), drafts: reduceDrafts(state.drafts, action), }; return fixUnreadActiveThread(state, action); } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how to " + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', "We're sorry, but your session was invalidated by the server. " + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. function fixUnreadActiveThread(state: AppState, action: *): AppState { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( activeThread && (NativeAppState.currentState === 'active' || (appLastBecameInactive + 10000 < Date.now() && !backgroundActionTypes.has(action.type))) && state.threadStore.threadInfos[activeThread] && state.threadStore.threadInfos[activeThread].currentUser.unread ) { state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } return state; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); const composeFunc = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux' }) : compose; let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/native/root.react.js b/native/root.react.js index 4c31cd49c..9bb74075d 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,276 +1,276 @@ // @flow import AsyncStorage from '@react-native-community/async-storage'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, View, StyleSheet, LogBox } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { actionLogger } from 'lib/utils/action-logger'; import ConnectedStatusBar from './connected-status-bar.react'; import ErrorBoundary from './error-boundary.react'; import InputStateContainer from './input/input-state-container.react'; import LifecycleHandler from './lifecycle/lifecycle-handler.react'; import { defaultNavigationState } from './navigation/default-state'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react'; import { setGlobalNavContext } from './navigation/icky-global'; import { NavContext } from './navigation/navigation-context'; import NavigationHandler from './navigation/navigation-handler.react'; import { validNavState } from './navigation/navigation-utils'; import OrientationHandler from './navigation/orientation-handler.react'; import { navStateAsyncStorageKey } from './navigation/persistance'; import RootNavigator from './navigation/root-navigator.react'; import ConnectivityUpdater from './redux/connectivity-updater.react'; import { DimensionsUpdater } from './redux/dimensions-updater.react'; import { getPersistor } from './redux/persist'; import { store } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import { RootContext } from './root-context'; import Socket from './socket.react'; import { DarkTheme, LightTheme } from './themes/navigation'; import ThemeHandler from './themes/theme-handler.react'; import './themes/fonts'; LogBox.ignoreLogs([ // react-native-reanimated 'Please report: Excessive number of pending callbacks', ]); if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); React.useEffect(() => { (async () => { try { await SplashScreen.preventAutoHideAsync(); } catch {} })(); }, []); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext((prevRootContext) => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector((state) => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } - for (let action of queuedActions) { + for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return; } return navContainer.addListener('__unsafe_action__', (event) => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); const AppRoot = () => ( ); export default AppRoot; diff --git a/native/selectors/calendar-selectors.js b/native/selectors/calendar-selectors.js index 8c138b67d..2036a6e9f 100644 --- a/native/selectors/calendar-selectors.js +++ b/native/selectors/calendar-selectors.js @@ -1,66 +1,66 @@ // @flow import { createSelector } from 'reselect'; import { currentDaysToEntries, threadInfoSelector, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { EntryInfo } from 'lib/types/entry-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { dateString } from 'lib/utils/date-utils'; import type { AppState } from '../redux/redux-setup'; export type SectionHeaderItem = {| itemType: 'header', dateString: string, |}; export type SectionFooterItem = {| itemType: 'footer', dateString: string, |}; export type LoaderItem = {| itemType: 'loader', key: string, |}; export type CalendarItem = | LoaderItem | SectionHeaderItem | SectionFooterItem | {| itemType: 'entryInfo', entryInfo: EntryInfo, threadInfo: ThreadInfo, |}; const calendarListData: (state: AppState) => ?(CalendarItem[]) = createSelector( isLoggedIn, currentDaysToEntries, threadInfoSelector, ( loggedIn: boolean, daysToEntries: { [dayString: string]: EntryInfo[] }, threadInfos: { [id: string]: ThreadInfo }, ) => { if (!loggedIn || daysToEntries[dateString(new Date())] === undefined) { return null; } const items: CalendarItem[] = [{ itemType: 'loader', key: 'TopLoader' }]; - for (let dayString in daysToEntries) { + for (const dayString in daysToEntries) { items.push({ itemType: 'header', dateString: dayString }); - for (let entryInfo of daysToEntries[dayString]) { + for (const entryInfo of daysToEntries[dayString]) { const threadInfo = threadInfos[entryInfo.threadID]; if (threadInfo) { items.push({ itemType: 'entryInfo', entryInfo, threadInfo }); } } items.push({ itemType: 'footer', dateString: dayString }); } items.push({ itemType: 'loader', key: 'BottomLoader' }); return items; }, ); export { calendarListData }; diff --git a/native/selectors/message-selectors.js b/native/selectors/message-selectors.js index 5dd6bbc41..aa7e65170 100644 --- a/native/selectors/message-selectors.js +++ b/native/selectors/message-selectors.js @@ -1,60 +1,60 @@ // @flow import { createSelector } from 'reselect'; import type { ThreadMessageInfo } from 'lib/types/message-types'; import { activeThreadSelector } from '../navigation/nav-selectors'; import type { AppState } from '../redux/redux-setup'; import type { NavPlusRedux } from '../types/selector-types'; const msInHour = 60 * 60 * 1000; const nextMessagePruneTimeSelector: ( state: AppState, ) => ?number = createSelector( (state: AppState) => state.messageStore.threads, (threadMessageInfos: { [id: string]: ThreadMessageInfo }): ?number => { let nextTime; - for (let threadID in threadMessageInfos) { + for (const threadID in threadMessageInfos) { const threadMessageInfo = threadMessageInfos[threadID]; const threadPruneTime = Math.max( threadMessageInfo.lastNavigatedTo + msInHour, threadMessageInfo.lastPruned + msInHour * 6, ); if (nextTime === undefined || threadPruneTime < nextTime) { nextTime = threadPruneTime; } } return nextTime; }, ); const pruneThreadIDsSelector: ( input: NavPlusRedux, ) => () => $ReadOnlyArray = createSelector( (input: NavPlusRedux) => input.redux.messageStore.threads, (input: NavPlusRedux) => activeThreadSelector(input.navContext), ( threadMessageInfos: { [id: string]: ThreadMessageInfo }, activeThread: ?string, ) => (): $ReadOnlyArray => { const now = Date.now(); const threadIDsToPrune = []; - for (let threadID in threadMessageInfos) { + for (const threadID in threadMessageInfos) { if (threadID === activeThread) { continue; } const threadMessageInfo = threadMessageInfos[threadID]; if ( threadMessageInfo.lastNavigatedTo + msInHour < now && threadMessageInfo.lastPruned + msInHour * 6 < now ) { threadIDsToPrune.push(threadID); } } return threadIDsToPrune; }, ); export { nextMessagePruneTimeSelector, pruneThreadIDsSelector }; diff --git a/native/themes/colors.js b/native/themes/colors.js index 2c29d2d0e..f9a038ef8 100644 --- a/native/themes/colors.js +++ b/native/themes/colors.js @@ -1,257 +1,257 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createSelector } from 'reselect'; import { selectBackgroundIsDark } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import type { GlobalTheme } from '../types/themes'; const light = Object.freeze({ redButton: '#BB8888', greenButton: '#6EC472', vibrantRedButton: '#F53100', vibrantGreenButton: '#00C853', mintButton: '#44CC99', redText: '#AA0000', greenText: 'green', link: '#036AFF', panelBackground: '#E9E9EF', panelBackgroundLabel: '#888888', panelForeground: 'white', panelForegroundBorder: '#CCCCCC', panelForegroundLabel: 'black', panelForegroundSecondaryLabel: '#333333', panelForegroundTertiaryLabel: '#888888', panelIosHighlightUnderlay: '#EEEEEEDD', panelSecondaryForeground: '#F5F5F5', panelSecondaryForegroundBorder: '#D1D1D6', modalForeground: 'white', modalForegroundBorder: '#CCCCCC', modalForegroundLabel: 'black', modalForegroundSecondaryLabel: '#888888', modalForegroundTertiaryLabel: '#AAAAAA', modalBackground: '#EEEEEE', modalBackgroundLabel: '#333333', modalBackgroundSecondaryLabel: '#AAAAAA', modalIosHighlightUnderlay: '#CCCCCCDD', modalSubtext: '#CCCCCC', modalSubtextLabel: '#555555', modalButton: '#BBBBBB', modalButtonLabel: 'black', modalContrastBackground: 'black', modalContrastForegroundLabel: 'white', modalContrastOpacity: 0.7, listForegroundLabel: 'black', listForegroundSecondaryLabel: '#333333', listForegroundTertiaryLabel: '#666666', listForegroundQuaternaryLabel: '#AAAAAA', listBackground: 'white', listBackgroundLabel: 'black', listBackgroundSecondaryLabel: '#444444', listBackgroundTernaryLabel: '#999999', listSeparator: '#EEEEEE', listSeparatorLabel: '#555555', listInputBar: '#E2E2E2', listInputBorder: '#AAAAAAAA', listInputButton: '#888888', listInputBackground: '#DDDDDD', listIosHighlightUnderlay: '#DDDDDDDD', listSearchBackground: '#DDDDDD', listSearchIcon: '#AAAAAA', listChatBubble: '#DDDDDDBB', navigationCard: '#FFFFFF', floatingButtonBackground: '#999999', floatingButtonLabel: '#EEEEEE', blockQuoteBackground: '#D3D3D3', blockQuoteBorder: '#C0C0C0', codeBackground: '#DCDCDC', disconnectedBarBackground: '#C6C6C6', }); export type Colors = $Exact; const dark: Colors = Object.freeze({ redButton: '#FF4444', greenButton: '#43A047', vibrantRedButton: '#F53100', vibrantGreenButton: '#00C853', mintButton: '#44CC99', redText: '#FF4444', greenText: '#44FF44', link: '#129AFF', panelBackground: '#1C1C1E', panelBackgroundLabel: '#C7C7CC', panelForeground: '#3A3A3C', panelForegroundBorder: '#2C2C2E', panelForegroundLabel: 'white', panelForegroundSecondaryLabel: '#CCCCCC', panelForegroundTertiaryLabel: '#AAAAAA', panelIosHighlightUnderlay: '#444444DD', panelSecondaryForeground: '#333333', panelSecondaryForegroundBorder: '#666666', modalForeground: '#1C1C1E', modalForegroundBorder: '#1C1C1E', modalForegroundLabel: 'white', modalForegroundSecondaryLabel: '#AAAAAA', modalForegroundTertiaryLabel: '#666666', modalBackground: '#2C2C2E', modalBackgroundLabel: '#CCCCCC', modalBackgroundSecondaryLabel: '#555555', modalIosHighlightUnderlay: '#AAAAAA88', modalSubtext: '#444444', modalSubtextLabel: '#AAAAAA', modalButton: '#666666', modalButtonLabel: 'white', modalContrastBackground: 'white', modalContrastForegroundLabel: 'black', modalContrastOpacity: 0.85, listForegroundLabel: 'white', listForegroundSecondaryLabel: '#CCCCCC', listForegroundTertiaryLabel: '#999999', listForegroundQuaternaryLabel: '#555555', listBackground: '#1C1C1E', listBackgroundLabel: '#C7C7CC', listBackgroundSecondaryLabel: '#BBBBBB', listBackgroundTernaryLabel: '#888888', listSeparator: '#3A3A3C', listSeparatorLabel: '#EEEEEE', listInputBar: '#555555', listInputBorder: '#333333', listInputButton: '#AAAAAA', listInputBackground: '#38383C', listIosHighlightUnderlay: '#BBBBBB88', listSearchBackground: '#555555', listSearchIcon: '#AAAAAA', listChatBubble: '#444444DD', navigationCard: '#2A2A2A', floatingButtonBackground: '#666666', floatingButtonLabel: 'white', blockQuoteBackground: '#A9A9A9', blockQuoteBorder: '#808080', codeBackground: '#222222', disconnectedBarBackground: '#666666', }); const colors = { light, dark }; const colorsSelector: (state: AppState) => Colors = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { const explicitTheme = theme ? theme : 'light'; return colors[explicitTheme]; }, ); const magicStrings = new Set(); -for (let theme in colors) { - for (let magicString in colors[theme]) { +for (const theme in colors) { + for (const magicString in colors[theme]) { magicStrings.add(magicString); } } type Styles = { [name: string]: { [field: string]: mixed } }; type ReplaceField = (input: any) => any; export type StyleSheetOf = $ObjMap; function stylesFromColors( obj: IS, themeColors: Colors, ): StyleSheetOf { const result = {}; - for (let key in obj) { + for (const key in obj) { const style = obj[key]; const filledInStyle = { ...style }; - for (let styleKey in style) { + for (const styleKey in style) { const styleValue = style[styleKey]; if (typeof styleValue !== 'string') { continue; } if (magicStrings.has(styleValue)) { const mapped = themeColors[styleValue]; if (mapped) { filledInStyle[styleKey] = mapped; } } } result[key] = filledInStyle; } return StyleSheet.create(result); } function styleSelector( obj: IS, ): (state: AppState) => StyleSheetOf { return createSelector(colorsSelector, (themeColors: Colors) => stylesFromColors(obj, themeColors), ); } function useStyles(obj: IS): StyleSheetOf { const ourColors = useColors(); return React.useMemo(() => stylesFromColors(obj, ourColors), [ obj, ourColors, ]); } function useOverlayStyles(obj: IS): StyleSheetOf { const navContext = React.useContext(NavContext); const navigationState = navContext && navContext.state; const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); const backgroundIsDark = React.useMemo( () => selectBackgroundIsDark(navigationState, theme), [navigationState, theme], ); const syntheticTheme = backgroundIsDark ? 'dark' : 'light'; return React.useMemo(() => stylesFromColors(obj, colors[syntheticTheme]), [ obj, syntheticTheme, ]); } function useColors(): Colors { return useSelector(colorsSelector); } function getStylesForTheme( obj: IS, theme: GlobalTheme, ): StyleSheetOf { return stylesFromColors(obj, colors[theme]); } export type IndicatorStyle = 'white' | 'black'; function useIndicatorStyle(): IndicatorStyle { const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); return theme && theme === 'dark' ? 'white' : 'black'; } const indicatorStyleSelector: ( state: AppState, ) => IndicatorStyle = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'white' : 'black'; }, ); export { colors, colorsSelector, styleSelector, useStyles, useOverlayStyles, useColors, getStylesForTheme, useIndicatorStyle, indicatorStyleSelector, }; diff --git a/native/utils/android-permissions.js b/native/utils/android-permissions.js index 8fb0ef9ca..b4e631a43 100644 --- a/native/utils/android-permissions.js +++ b/native/utils/android-permissions.js @@ -1,119 +1,119 @@ // @flow import { PermissionsAndroid } from 'react-native'; import { getMessageForException } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; const granted = new Set(); type CheckOrRequest = 'check' | 'request'; type ThrowExceptions = 'throw' | typeof undefined; async function getAndroidPermissions( permissions: $ReadOnlyArray, checkOrRequest: CheckOrRequest, throwExceptions?: ThrowExceptions, ) { const result = {}, missing = []; - for (let permission of permissions) { + for (const permission of permissions) { if (granted.has(permission)) { result[permission] = true; } else { missing.push(permission); } } if (missing.length === 0) { return result; } if (checkOrRequest === 'check') { - for (let permission of missing) { + for (const permission of missing) { result[permission] = (async () => { try { return await PermissionsAndroid.check(permission); } catch (e) { printException(e, 'PermissionsAndroid.check'); if (throwExceptions === 'throw') { throw e; } return false; } })(); } return await promiseAll(result); } let requestResult = {}; try { requestResult = await PermissionsAndroid.requestMultiple(missing); } catch (e) { printException(e, 'PermissionsAndroid.requestMultiple'); if (throwExceptions === 'throw') { throw e; } } - for (let permission of missing) { + for (const permission of missing) { result[permission] = requestResult[permission] === PermissionsAndroid.RESULTS.GRANTED; } return result; } function printException(e: mixed, caller: string) { const exceptionMessage = getMessageForException(e); const suffix = exceptionMessage ? `: ${exceptionMessage}` : ''; console.log(`${caller} returned exception${suffix}`); } function requestAndroidPermissions( permissions: $ReadOnlyArray, throwExceptions?: ThrowExceptions, ) { return getAndroidPermissions(permissions, 'request', throwExceptions); } function checkAndroidPermissions( permissions: $ReadOnlyArray, throwExceptions?: ThrowExceptions, ) { return getAndroidPermissions(permissions, 'check', throwExceptions); } async function getAndroidPermission( permission: string, checkOrRequest: CheckOrRequest, throwExceptions?: ThrowExceptions, ) { const result = await getAndroidPermissions( [permission], checkOrRequest, throwExceptions, ); return result[permission]; } function requestAndroidPermission( permission: string, throwExceptions?: ThrowExceptions, ) { return getAndroidPermission(permission, 'request', throwExceptions); } function checkAndroidPermission( permission: string, throwExceptions?: ThrowExceptions, ) { return getAndroidPermission(permission, 'check', throwExceptions); } export { getAndroidPermissions, requestAndroidPermissions, checkAndroidPermissions, getAndroidPermission, requestAndroidPermission, checkAndroidPermission, }; diff --git a/server/src/bots/app-version-update.js b/server/src/bots/app-version-update.js index 5229c3300..4399f8e00 100644 --- a/server/src/bots/app-version-update.js +++ b/server/src/bots/app-version-update.js @@ -1,108 +1,108 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { messageTypes } from 'lib/types/message-types'; import { promiseAll } from 'lib/utils/promises'; import createMessages from '../creators/message-creator'; import { dbQuery, SQL } from '../database/database'; import { createBotViewer } from '../session/bots'; import { createSquadbotThread } from './squadbot'; const thirtyDays = 30 * 24 * 60 * 60 * 1000; const { squadbot } = bots; async function tryCreateSquadbotThread(userID: string) { try { return await createSquadbotThread(userID); } catch { return null; } } async function botherMonthlyActivesToUpdateAppVersion(): Promise { const cutoff = Date.now() - thirtyDays; const query = SQL` SELECT x.user, MIN(x.max_code_version) AS code_version, MIN(t.id) AS squadbot_thread FROM ( SELECT s.user, c.platform, MAX(JSON_EXTRACT(c.versions, "$.codeVersion")) AS max_code_version FROM sessions s LEFT JOIN cookies c ON c.id = s.cookie WHERE s.last_update > ${cutoff} AND c.platform != "web" AND JSON_EXTRACT(c.versions, "$.codeVersion") IS NOT NULL GROUP BY s.user, c.platform ) x LEFT JOIN versions v ON v.platform = x.platform AND v.code_version = ( SELECT MAX(code_version) FROM versions WHERE platform = x.platform AND deploy_time IS NOT NULL ) LEFT JOIN ( SELECT t.id, m1.user, COUNT(m3.user) AS user_count FROM threads t LEFT JOIN memberships m1 ON m1.thread = t.id AND m1.user != ${squadbot.userID} AND m1.role >= 0 LEFT JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${squadbot.userID} AND m2.role >= 0 LEFT JOIN memberships m3 ON m3.thread = t.id WHERE m1.user IS NOT NULL AND m2.user IS NOT NULL AND m3.role >= 0 GROUP BY t.id, m1.user ) t ON t.user = x.user AND t.user_count = 2 WHERE v.id IS NOT NULL AND x.max_code_version < v.code_version GROUP BY x.user `; const [result] = await dbQuery(query); const codeVersions = new Map(); const squadbotThreads = new Map(); const usersToSquadbotThreadPromises = {}; - for (let row of result) { + for (const row of result) { const userID = row.user.toString(); const codeVersion = row.code_version; codeVersions.set(userID, codeVersion); if (row.squadbot_thread) { const squadbotThread = row.squadbot_thread.toString(); squadbotThreads.set(userID, squadbotThread); } else { usersToSquadbotThreadPromises[userID] = tryCreateSquadbotThread(userID); } } const newSquadbotThreads = await promiseAll(usersToSquadbotThreadPromises); for (const userID in newSquadbotThreads) { const newSquadbotThreadID = newSquadbotThreads[userID]; if (newSquadbotThreadID) { squadbotThreads.set(userID, newSquadbotThreadID); } } const time = Date.now(); const newMessageDatas = []; - for (let [userID, threadID] of squadbotThreads) { + for (const [userID, threadID] of squadbotThreads) { const codeVersion = codeVersions.get(userID); invariant(codeVersion, 'should be set'); newMessageDatas.push({ type: messageTypes.TEXT, threadID, creatorID: squadbot.userID, time, text: `beep boop, I'm a bot! one or more of your devices is on an old ` + `version (v${codeVersion}). any chance you could update it? on ` + `Android you do this using the Play Store, same as any other app. on ` + `iOS, you need to open up the Testflight app and update from there. ` + `thanks for helping test!`, }); } const squadbotViewer = createBotViewer(squadbot.userID); await createMessages(squadbotViewer, newMessageDatas); } export { botherMonthlyActivesToUpdateAppVersion }; diff --git a/server/src/creators/report-creator.js b/server/src/creators/report-creator.js index bd972107e..095095a04 100644 --- a/server/src/creators/report-creator.js +++ b/server/src/creators/report-creator.js @@ -1,232 +1,232 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import bots from 'lib/facts/bots'; import { filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from 'lib/shared/entry-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ReportCreationRequest, type ReportCreationResponse, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, type UserInconsistencyReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import { values } from 'lib/utils/objects'; import { sanitizeAction, sanitizeState } from 'lib/utils/sanitization'; import urlFacts from '../../facts/url'; import { dbQuery, SQL } from '../database/database'; import { fetchUsername } from '../fetchers/user-fetchers'; import { handleAsyncPromise } from '../responders/handlers'; import { createBotViewer } from '../session/bots'; import type { Viewer } from '../session/viewer'; import createIDs from './id-creator'; import createMessages from './message-creator'; const { baseDomain, basePath } = urlFacts; const { squadbot } = bots; async function createReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { const shouldIgnore = await ignoreReport(viewer, request); if (shouldIgnore) { return null; } const [id] = await createIDs('reports', 1); let type, report, time; if (request.type === reportTypes.THREAD_INCONSISTENCY) { ({ type, time, ...report } = request); time = time ? time : Date.now(); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { ({ type, time, ...report } = request); } else if (request.type === reportTypes.MEDIA_MISSION) { ({ type, time, ...report } = request); } else if (request.type === reportTypes.USER_INCONSISTENCY) { ({ type, time, ...report } = request); } else { ({ type, ...report } = request); time = Date.now(); report = { ...report, preloadedState: sanitizeState(report.preloadedState), currentState: sanitizeState(report.currentState), actions: report.actions.map(sanitizeAction), }; } const row = [ id, viewer.id, type, request.platformDetails.platform, JSON.stringify(report), time, ]; const query = SQL` INSERT INTO reports (id, user, type, platform, report, creation_time) VALUES ${[row]} `; await dbQuery(query); handleAsyncPromise(sendSquadbotMessage(viewer, request, id)); return { id }; } async function sendSquadbotMessage( viewer: Viewer, request: ReportCreationRequest, reportID: string, ): Promise { const canGenerateMessage = getSquadbotMessage(request, reportID, null); if (!canGenerateMessage) { return; } const username = await fetchUsername(viewer.id); const message = getSquadbotMessage(request, reportID, username); if (!message) { return; } const time = Date.now(); await createMessages(createBotViewer(squadbot.userID), [ { type: messageTypes.TEXT, threadID: squadbot.staffThreadID, creatorID: squadbot.userID, time, text: message, }, ]); } async function ignoreReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { // The below logic is to avoid duplicate inconsistency reports if ( request.type !== reportTypes.THREAD_INCONSISTENCY && request.type !== reportTypes.ENTRY_INCONSISTENCY ) { return false; } const { type, platformDetails, time } = request; if (!time) { return false; } const { platform } = platformDetails; const query = SQL` SELECT id FROM reports WHERE user = ${viewer.id} AND type = ${type} AND platform = ${platform} AND creation_time = ${time} `; const [result] = await dbQuery(query); return result.length !== 0; } function getSquadbotMessage( request: ReportCreationRequest, reportID: string, username: ?string, ): ?string { const name = username ? username : '[null]'; const { platformDetails } = request; const { platform, codeVersion } = platformDetails; const platformString = codeVersion ? `${platform} v${codeVersion}` : platform; if (request.type === reportTypes.ERROR) { return ( `${name} got an error :(\n` + `using ${platformString}\n` + `${baseDomain}${basePath}download_error_report/${reportID}` ); } else if (request.type === reportTypes.THREAD_INCONSISTENCY) { const nonMatchingThreadIDs = getInconsistentThreadIDsFromReport(request); const nonMatchingString = [...nonMatchingThreadIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `thread IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { const nonMatchingEntryIDs = getInconsistentEntryIDsFromReport(request); const nonMatchingString = [...nonMatchingEntryIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `entry IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.USER_INCONSISTENCY) { const nonMatchingUserIDs = getInconsistentUserIDsFromReport(request); const nonMatchingString = [...nonMatchingUserIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `user IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.MEDIA_MISSION) { const mediaMissionJSON = JSON.stringify(request.mediaMission); const success = request.mediaMission.result.success ? 'media mission success!' : 'media mission failed :('; return `${name} ${success}\n` + mediaMissionJSON; } else { return null; } } function findInconsistentObjectKeys( first: { +[id: string]: O }, second: { +[id: string]: O }, ): Set { const nonMatchingIDs = new Set(); - for (let id in first) { + for (const id in first) { if (!_isEqual(first[id])(second[id])) { nonMatchingIDs.add(id); } } - for (let id in second) { + for (const id in second) { if (!first[id]) { nonMatchingIDs.add(id); } } return nonMatchingIDs; } function getInconsistentThreadIDsFromReport( request: ThreadInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction } = request; return findInconsistentObjectKeys(beforeAction, pushResult); } function getInconsistentEntryIDsFromReport( request: EntryInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction, calendarQuery } = request; const filteredBeforeAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeAction)), calendarQuery, ); const filteredAfterAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(pushResult)), calendarQuery, ); return findInconsistentObjectKeys(filteredBeforeAction, filteredAfterAction); } function getInconsistentUserIDsFromReport( request: UserInconsistencyReportCreationRequest, ): Set { const { beforeStateCheck, afterStateCheck } = request; return findInconsistentObjectKeys(beforeStateCheck, afterStateCheck); } export default createReport; diff --git a/server/src/creators/update-creator.js b/server/src/creators/update-creator.js index 1873807ca..1eeb4454f 100644 --- a/server/src/creators/update-creator.js +++ b/server/src/creators/update-creator.js @@ -1,756 +1,756 @@ // @flow import invariant from 'invariant'; import { nonThreadCalendarFilters } from 'lib/selectors/calendar-filter-selectors'; import { keyForUpdateData, keyForUpdateInfo, rawUpdateInfoFromUpdateData, } from 'lib/shared/update-utils'; import { type RawEntryInfo, type FetchEntryInfosBase, type CalendarQuery, defaultCalendarQuery, } from 'lib/types/entry-types'; import { defaultNumberPerThread, type FetchMessageInfosResult, } from 'lib/types/message-types'; import { type UpdateTarget, redisMessageTypes, type NewUpdatesRedisMessage, } from 'lib/types/redis-types'; import type { RawThreadInfo } from 'lib/types/thread-types'; import { type UpdateInfo, type UpdateData, type RawUpdateInfo, type CreateUpdatesResult, updateTypes, } from 'lib/types/update-types'; import type { AccountUserInfo, LoggedInUserInfo } from 'lib/types/user-types'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL, SQLStatement, mergeAndConditions, } from '../database/database'; import { deleteUpdatesByConditions } from '../deleters/update-deleters'; import { fetchEntryInfos, fetchEntryInfosByID, } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfos, } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { channelNameForUpdateTarget, publisher } from '../socket/redis'; import createIDs from './id-creator'; export type UpdatesForCurrentSession = // This is the default if no Viewer is passed, or if an isSocket Viewer is // passed in. We will broadcast to all valid sessions via Redis and return // nothing to the caller, relying on the current session's Redis listener to // pick up the updates and deliver them asynchronously. | 'broadcast' // This is the default if a non-isSocket Viewer is passed in. We avoid // broadcasting the update to the current session, and instead return the // update to the caller, who will handle delivering it to the client. | 'return' // This means we ignore any updates destined for the current session. // Presumably the caller knows what they are doing and has a different way of // communicating the relevant information to the client. | 'ignore'; type DeleteCondition = {| +userID: string, +target: ?string, +types: 'all_types' | $ReadOnlySet, |}; export type ViewerInfo = | {| viewer: Viewer, calendarQuery?: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, |} | {| viewer: Viewer, calendarQuery: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, threadInfos: { [id: string]: RawThreadInfo }, |}; const defaultUpdateCreationResult = { viewerUpdates: [], userInfos: {} }; const sortFunction = (a: UpdateData | UpdateInfo, b: UpdateData | UpdateInfo) => a.time - b.time; // Creates rows in the updates table based on the inputed updateDatas. Returns // UpdateInfos pertaining to the provided viewerInfo, as well as related // UserInfos. If no viewerInfo is provided, no UpdateInfos will be returned. And // the update row won't have an updater column, meaning no session will be // excluded from the update. async function createUpdates( updateDatas: $ReadOnlyArray, passedViewerInfo?: ?ViewerInfo, ): Promise { if (updateDatas.length === 0) { return defaultUpdateCreationResult; } // viewer.session will throw for a script Viewer let viewerInfo = passedViewerInfo; if ( viewerInfo && (viewerInfo.viewer.isScriptViewer || !viewerInfo.viewer.loggedIn) ) { viewerInfo = null; } const sortedUpdateDatas = [...updateDatas].sort(sortFunction); const filteredUpdateDatas: UpdateData[] = []; const keyedUpdateDatas: Map = new Map(); for (const updateData of sortedUpdateDatas) { const key = keyForUpdateData(updateData); if (!key) { filteredUpdateDatas.push(updateData); continue; } const conditionKey = `${updateData.userID}|${key}`; const deleteCondition = getDeleteCondition(updateData); invariant( deleteCondition, `updateData of type ${updateData.type} has conditionKey ` + `${conditionKey} but no deleteCondition`, ); const curUpdateDatas = keyedUpdateDatas.get(conditionKey); if (!curUpdateDatas) { keyedUpdateDatas.set(conditionKey, [updateData]); continue; } const filteredCurrent = curUpdateDatas.filter((curUpdateData) => filterOnDeleteCondition(curUpdateData, deleteCondition), ); if (filteredCurrent.length === 0) { keyedUpdateDatas.set(conditionKey, [updateData]); continue; } const isNewUpdateDataFiltered = !filteredCurrent.every((curUpdateData) => { const curDeleteCondition = getDeleteCondition(curUpdateData); invariant( curDeleteCondition, `updateData of type ${curUpdateData.type} is in keyedUpdateDatas ` + "but doesn't have a deleteCondition", ); return filterOnDeleteCondition(updateData, curDeleteCondition); }); if (!isNewUpdateDataFiltered) { filteredCurrent.push(updateData); } keyedUpdateDatas.set(conditionKey, filteredCurrent); } for (const keyUpdateDatas of keyedUpdateDatas.values()) { filteredUpdateDatas.push(...keyUpdateDatas); } const ids = await createIDs('updates', filteredUpdateDatas.length); let updatesForCurrentSession = viewerInfo && viewerInfo.updatesForCurrentSession; if (!updatesForCurrentSession && viewerInfo) { updatesForCurrentSession = viewerInfo.viewer.isSocket ? 'broadcast' : 'return'; } else if (!updatesForCurrentSession) { updatesForCurrentSession = 'broadcast'; } const dontBroadcastSession = updatesForCurrentSession !== 'broadcast' && viewerInfo ? viewerInfo.viewer.session : null; const publishInfos: Map = new Map(); const viewerRawUpdateInfos: RawUpdateInfo[] = []; const insertRows: (?(number | string))[][] = []; const earliestTime: Map = new Map(); for (let i = 0; i < filteredUpdateDatas.length; i++) { const updateData = filteredUpdateDatas[i]; let content; if (updateData.type === updateTypes.DELETE_ACCOUNT) { content = JSON.stringify({ deletedUserID: updateData.deletedUserID }); } else if (updateData.type === updateTypes.UPDATE_THREAD) { content = JSON.stringify({ threadID: updateData.threadID }); } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = updateData; content = JSON.stringify({ threadID, unread }); } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { const { threadID } = updateData; content = JSON.stringify({ threadID }); } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken } = updateData; content = JSON.stringify({ deviceToken }); } else if (updateData.type === updateTypes.UPDATE_ENTRY) { const { entryID } = updateData; content = JSON.stringify({ entryID }); } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { // user column contains all the info we need to construct the UpdateInfo content = null; } else if (updateData.type === updateTypes.UPDATE_USER) { const { updatedUserID } = updateData; content = JSON.stringify({ updatedUserID }); } else { invariant(false, `unrecognized updateType ${updateData.type}`); } const target = getTargetFromUpdateData(updateData); const rawUpdateInfo = rawUpdateInfoFromUpdateData(updateData, ids[i]); if (!target || !dontBroadcastSession || target !== dontBroadcastSession) { const updateTarget = target ? { userID: updateData.userID, sessionID: target } : { userID: updateData.userID }; const channelName = channelNameForUpdateTarget(updateTarget); let publishInfo = publishInfos.get(channelName); if (!publishInfo) { publishInfo = { updateTarget, rawUpdateInfos: [] }; publishInfos.set(channelName, publishInfo); } publishInfo.rawUpdateInfos.push(rawUpdateInfo); } if ( updatesForCurrentSession === 'return' && viewerInfo && updateData.userID === viewerInfo.viewer.id && (!target || target === viewerInfo.viewer.session) ) { viewerRawUpdateInfos.push(rawUpdateInfo); } if (viewerInfo && target && viewerInfo.viewer.session === target) { // In the case where this update is being created only for the current // session, there's no reason to insert a row into the updates table continue; } const key = keyForUpdateData(updateData); if (key) { const conditionKey = `${updateData.userID}|${key}`; const currentEarliestTime = earliestTime.get(conditionKey); if (!currentEarliestTime || updateData.time < currentEarliestTime) { earliestTime.set(conditionKey, updateData.time); } } const insertRow = [ ids[i], updateData.userID, updateData.type, key, content, updateData.time, dontBroadcastSession, target, ]; insertRows.push(insertRow); } const deleteSQLConditions: SQLStatement[] = []; for (const [conditionKey, keyUpdateDatas] of keyedUpdateDatas) { const deleteConditionByTarget: Map = new Map(); for (const updateData of keyUpdateDatas) { const deleteCondition = getDeleteCondition(updateData); invariant( deleteCondition, `updateData of type ${updateData.type} is in keyedUpdateDatas but ` + "doesn't have a deleteCondition", ); const { target, types } = deleteCondition; const existingDeleteCondition = deleteConditionByTarget.get(target); if (!existingDeleteCondition) { deleteConditionByTarget.set(target, deleteCondition); continue; } const existingTypes = existingDeleteCondition.types; if (existingTypes === 'all_types') { continue; } else if (types === 'all_types') { deleteConditionByTarget.set(target, deleteCondition); continue; } const mergedTypes = new Set([...types, ...existingTypes]); deleteConditionByTarget.set(target, { ...deleteCondition, types: mergedTypes, }); } for (const deleteCondition of deleteConditionByTarget.values()) { const { userID, target, types } = deleteCondition; const key = conditionKey.split('|')[1]; const conditions = [SQL`u.user = ${userID}`, SQL`u.key = ${key}`]; if (target) { conditions.push(SQL`u.target = ${target}`); } if (types !== 'all_types') { invariant(types.size > 0, 'deleteCondition had empty types set'); conditions.push(SQL`u.type IN (${[...types]})`); } const earliestTimeForCondition = earliestTime.get(conditionKey); if (earliestTimeForCondition) { conditions.push(SQL`u.time < ${earliestTimeForCondition}`); } deleteSQLConditions.push(mergeAndConditions(conditions)); } } const promises = {}; if (insertRows.length > 0) { const insertQuery = SQL` INSERT INTO updates(id, user, type, \`key\`, content, time, updater, target) `; insertQuery.append(SQL`VALUES ${insertRows}`); promises.insert = dbQuery(insertQuery); } if (publishInfos.size > 0) { promises.redis = redisPublish(publishInfos.values(), dontBroadcastSession); } if (deleteSQLConditions.length > 0) { promises.delete = deleteUpdatesByConditions(deleteSQLConditions); } if (viewerRawUpdateInfos.length > 0) { invariant(viewerInfo, 'should be set'); promises.updatesResult = fetchUpdateInfosWithRawUpdateInfos( viewerRawUpdateInfos, viewerInfo, ); } const { updatesResult } = await promiseAll(promises); if (!updatesResult) { return defaultUpdateCreationResult; } const { updateInfos, userInfos } = updatesResult; return { viewerUpdates: updateInfos, userInfos }; } export type FetchUpdatesResult = {| updateInfos: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; async function fetchUpdateInfosWithRawUpdateInfos( rawUpdateInfos: $ReadOnlyArray, viewerInfo: ViewerInfo, ): Promise { const { viewer } = viewerInfo; const threadIDsNeedingFetch = new Set(); const entryIDsNeedingFetch = new Set(); const currentUserIDsNeedingFetch = new Set(); const threadIDsNeedingDetailedFetch = new Set(); // entries and messages - for (let rawUpdateInfo of rawUpdateInfos) { + for (const rawUpdateInfo of rawUpdateInfos) { if ( !viewerInfo.threadInfos && (rawUpdateInfo.type === updateTypes.UPDATE_THREAD || rawUpdateInfo.type === updateTypes.JOIN_THREAD) ) { threadIDsNeedingFetch.add(rawUpdateInfo.threadID); } if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { threadIDsNeedingDetailedFetch.add(rawUpdateInfo.threadID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { entryIDsNeedingFetch.add(rawUpdateInfo.entryID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { currentUserIDsNeedingFetch.add(viewer.userID); } } const promises = {}; if (!viewerInfo.threadInfos && threadIDsNeedingFetch.size > 0) { promises.threadResult = fetchThreadInfos( viewer, SQL`t.id IN (${[...threadIDsNeedingFetch]})`, ); } let calendarQuery: ?CalendarQuery = viewerInfo.calendarQuery ? viewerInfo.calendarQuery : null; if (!calendarQuery && viewer.hasSessionInfo) { // This should only ever happen for "legacy" clients who call in without // providing this information. These clients wouldn't know how to deal with // the corresponding UpdateInfos anyways, so no reason to be worried. calendarQuery = viewer.calendarQuery; } else if (!calendarQuery) { calendarQuery = defaultCalendarQuery(viewer.platform, viewer.timeZone); } if (threadIDsNeedingDetailedFetch.size > 0) { const threadSelectionCriteria = { threadCursors: {} }; - for (let threadID of threadIDsNeedingDetailedFetch) { + for (const threadID of threadIDsNeedingDetailedFetch) { threadSelectionCriteria.threadCursors[threadID] = false; } promises.messageInfosResult = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const threadCalendarQuery = { ...calendarQuery, filters: [ ...nonThreadCalendarFilters(calendarQuery.filters), { type: 'threads', threadIDs: [...threadIDsNeedingDetailedFetch] }, ], }; promises.calendarResult = fetchEntryInfos(viewer, [threadCalendarQuery]); } if (entryIDsNeedingFetch.size > 0) { promises.entryInfosResult = fetchEntryInfosByID(viewer, [ ...entryIDsNeedingFetch, ]); } if (currentUserIDsNeedingFetch.size > 0) { promises.currentUserInfosResult = fetchLoggedInUserInfos([ ...currentUserIDsNeedingFetch, ]); } const { threadResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, } = await promiseAll(promises); let threadInfosResult; if (viewerInfo.threadInfos) { const { threadInfos } = viewerInfo; threadInfosResult = { threadInfos }; } else if (threadResult) { threadInfosResult = threadResult; } else { threadInfosResult = { threadInfos: {} }; } return await updateInfosFromRawUpdateInfos(viewer, rawUpdateInfos, { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, }); } export type UpdateInfosRawData = {| threadInfosResult: FetchThreadInfosResult, messageInfosResult: ?FetchMessageInfosResult, calendarResult: ?FetchEntryInfosBase, entryInfosResult: ?$ReadOnlyArray, currentUserInfosResult: ?$ReadOnlyArray, |}; async function updateInfosFromRawUpdateInfos( viewer: Viewer, rawUpdateInfos: $ReadOnlyArray, rawData: UpdateInfosRawData, ): Promise { const { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, } = rawData; const updateInfos = []; const userIDsToFetch = new Set(); - for (let rawUpdateInfo of rawUpdateInfos) { + for (const rawUpdateInfo of rawUpdateInfos) { if (rawUpdateInfo.type === updateTypes.DELETE_ACCOUNT) { updateInfos.push({ type: updateTypes.DELETE_ACCOUNT, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deletedUserID: rawUpdateInfo.deletedUserID, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; if (!threadInfo) { console.warn( "failed to hydrate updateTypes.UPDATE_THREAD because we couldn't " + `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`, ); continue; } updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS) { updateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, unread: rawUpdateInfo.unread, }); } else if (rawUpdateInfo.type === updateTypes.DELETE_THREAD) { updateInfos.push({ type: updateTypes.DELETE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, }); } else if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; if (!threadInfo) { console.warn( "failed to hydrate updateTypes.JOIN_THREAD because we couldn't " + `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`, ); continue; } const rawEntryInfos = []; invariant(calendarResult, 'should be set'); - for (let entryInfo of calendarResult.rawEntryInfos) { + for (const entryInfo of calendarResult.rawEntryInfos) { if (entryInfo.threadID === rawUpdateInfo.threadID) { rawEntryInfos.push(entryInfo); } } const rawMessageInfos = []; invariant(messageInfosResult, 'should be set'); - for (let messageInfo of messageInfosResult.rawMessageInfos) { + for (const messageInfo of messageInfosResult.rawMessageInfos) { if (messageInfo.threadID === rawUpdateInfo.threadID) { rawMessageInfos.push(messageInfo); } } updateInfos.push({ type: updateTypes.JOIN_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, rawMessageInfos, truncationStatus: messageInfosResult.truncationStatuses[rawUpdateInfo.threadID], rawEntryInfos, }); } else if (rawUpdateInfo.type === updateTypes.BAD_DEVICE_TOKEN) { updateInfos.push({ type: updateTypes.BAD_DEVICE_TOKEN, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deviceToken: rawUpdateInfo.deviceToken, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { invariant(entryInfosResult, 'should be set'); const entryInfo = entryInfosResult.find( (candidate) => candidate.id === rawUpdateInfo.entryID, ); if (!entryInfo) { console.warn( "failed to hydrate updateTypes.UPDATE_ENTRY because we couldn't " + `fetch RawEntryInfo for ${rawUpdateInfo.entryID}`, ); continue; } updateInfos.push({ type: updateTypes.UPDATE_ENTRY, id: rawUpdateInfo.id, time: rawUpdateInfo.time, entryInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { invariant(currentUserInfosResult, 'should be set'); const currentUserInfo = currentUserInfosResult.find( (candidate) => candidate.id === viewer.userID, ); if (!currentUserInfo) { console.warn( 'failed to hydrate updateTypes.UPDATE_CURRENT_USER because we ' + `couldn't fetch CurrentUserInfo for ${viewer.userID}`, ); continue; } updateInfos.push({ type: updateTypes.UPDATE_CURRENT_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, currentUserInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_USER) { updateInfos.push({ type: updateTypes.UPDATE_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, updatedUserID: rawUpdateInfo.updatedUserID, }); userIDsToFetch.add(rawUpdateInfo.updatedUserID); } else { invariant(false, `unrecognized updateType ${rawUpdateInfo.type}`); } } let userInfos = {}; if (userIDsToFetch.size > 0) { userInfos = await fetchKnownUserInfos(viewer, [...userIDsToFetch]); } updateInfos.sort(sortFunction); // Now we'll attempt to merge UpdateInfos so that we only have one per key const updateForKey: Map = new Map(); const mergedUpdates: UpdateInfo[] = []; - for (let updateInfo of updateInfos) { + for (const updateInfo of updateInfos) { const key = keyForUpdateInfo(updateInfo); if (!key) { mergedUpdates.push(updateInfo); continue; } else if ( updateInfo.type === updateTypes.DELETE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD || updateInfo.type === updateTypes.DELETE_ACCOUNT ) { updateForKey.set(key, updateInfo); continue; } const currentUpdateInfo = updateForKey.get(key); if (!currentUpdateInfo) { updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // UPDATE_THREAD trumps UPDATE_THREAD_READ_STATUS // Note that we keep the oldest UPDATE_THREAD updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // If we only have UPDATE_THREAD_READ_STATUS, keep the most recent updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { updateForKey.set(key, updateInfo); } } - for (let [, updateInfo] of updateForKey) { + for (const [, updateInfo] of updateForKey) { mergedUpdates.push(updateInfo); } mergedUpdates.sort(sortFunction); return { updateInfos: mergedUpdates, userInfos }; } type PublishInfo = {| updateTarget: UpdateTarget, rawUpdateInfos: RawUpdateInfo[], |}; async function redisPublish( publishInfos: Iterator, dontBroadcastSession: ?string, ): Promise { - for (let publishInfo of publishInfos) { + for (const publishInfo of publishInfos) { const { updateTarget, rawUpdateInfos } = publishInfo; const redisMessage: NewUpdatesRedisMessage = { type: redisMessageTypes.NEW_UPDATES, updates: rawUpdateInfos, }; if (!updateTarget.sessionID && dontBroadcastSession) { redisMessage.ignoreSession = dontBroadcastSession; } publisher.sendMessage(updateTarget, redisMessage); } } function getTargetFromUpdateData(updateData: UpdateData): ?string { if (updateData.targetSession) { return updateData.targetSession; } else if (updateData.targetCookie) { return updateData.targetCookie; } else { return null; } } function getDeleteCondition(updateData: UpdateData): ?DeleteCondition { let types; if (updateData.type === updateTypes.DELETE_ACCOUNT) { types = new Set([updateTypes.DELETE_ACCOUNT, updateTypes.UPDATE_USER]); } else if (updateData.type === updateTypes.UPDATE_THREAD) { types = new Set([ updateTypes.UPDATE_THREAD, updateTypes.UPDATE_THREAD_READ_STATUS, ]); } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { types = new Set([updateTypes.UPDATE_THREAD_READ_STATUS]); } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { types = 'all_types'; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { types = 'all_types'; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { types = new Set([updateTypes.UPDATE_CURRENT_USER]); } else if (updateData.type === updateTypes.UPDATE_USER) { types = new Set([updateTypes.UPDATE_USER]); } else { return null; } const target = getTargetFromUpdateData(updateData); const { userID } = updateData; return { userID, target, types }; } function filterOnDeleteCondition( updateData: UpdateData, deleteCondition: DeleteCondition, ): boolean { invariant( updateData.userID === deleteCondition.userID, `updateData of type ${updateData.type} being compared to wrong userID`, ); if (deleteCondition.target) { const target = getTargetFromUpdateData(updateData); if (target !== deleteCondition.target) { return true; } } if (deleteCondition.types === 'all_types') { return false; } return !deleteCondition.types.has(updateData.type); } export { createUpdates, fetchUpdateInfosWithRawUpdateInfos }; diff --git a/server/src/cron/backups.js b/server/src/cron/backups.js index 37b3f0201..4977cc3f0 100644 --- a/server/src/cron/backups.js +++ b/server/src/cron/backups.js @@ -1,173 +1,173 @@ // @flow import childProcess from 'child_process'; import dateFormat from 'dateformat'; import fs from 'fs'; import invariant from 'invariant'; import { ReReadable } from 'rereadable-stream'; import { promisify } from 'util'; import zlib from 'zlib'; import dbConfig from '../../secrets/db_config'; const readdir = promisify(fs.readdir); const lstat = promisify(fs.lstat); const unlink = promisify(fs.unlink); let importedBackupConfig = undefined; async function importBackupConfig() { if (importedBackupConfig !== undefined) { return importedBackupConfig; } try { // $FlowFixMe const backupExports = await import('../../facts/backups'); if (importedBackupConfig === undefined) { importedBackupConfig = backupExports.default; } } catch { if (importedBackupConfig === undefined) { importedBackupConfig = null; } } return importedBackupConfig; } async function backupDB() { const backupConfig = await importBackupConfig(); if (!backupConfig || !backupConfig.enabled) { return; } const dateString = dateFormat('yyyy-mm-dd-HH:MM'); const filename = `squadcal.${dateString}.sql.gz`; const filePath = `${backupConfig.directory}/${filename}`; const mysqlDump = childProcess.spawn( 'mysqldump', [ '-u', dbConfig.user, `-p${dbConfig.password}`, '--single-transaction', dbConfig.database, ], { stdio: ['ignore', 'pipe', 'ignore'], }, ); const cache = new ReReadable(); mysqlDump.on('error', (e: Error) => { console.warn(`error trying to spawn mysqldump for ${filename}`, e); }); mysqlDump.on('exit', (code: number | null, signal: string | null) => { if (signal !== null && signal !== undefined) { console.warn(`mysqldump received signal ${signal} for ${filename}`); } else if (code !== null && code !== 0) { console.warn(`mysqldump exited with code ${code} for ${filename}`); } }); mysqlDump.stdout .on('error', (e: Error) => { console.warn(`mysqldump stdout stream emitted error for ${filename}`, e); }) .pipe(zlib.createGzip()) .on('error', (e: Error) => { console.warn(`gzip transform stream emitted error for ${filename}`, e); }) .pipe(cache); try { await saveBackup(filename, filePath, cache); } catch (e) { console.warn(`saveBackup threw for ${filename}`, e); await unlink(filePath); } } async function saveBackup( filename: string, filePath: string, cache: ReReadable, retries: number = 2, ): Promise { try { await trySaveBackup(filename, filePath, cache); } catch (e) { if (e.code !== 'ENOSPC') { throw e; } if (!retries) { throw e; } await deleteOldestBackup(); await saveBackup(filename, filePath, cache, retries - 1); } } const backupWatchFrequency = 60 * 1000; function trySaveBackup( filename: string, filePath: string, cache: ReReadable, ): Promise { const timeoutObject: {| timeout: ?TimeoutID |} = { timeout: null }; const setBackupTimeout = (alreadyWaited: number) => { timeoutObject.timeout = setTimeout(() => { const nowWaited = alreadyWaited + backupWatchFrequency; console.log( `writing backup for ${filename} has taken ${nowWaited}ms so far`, ); setBackupTimeout(nowWaited); }, backupWatchFrequency); }; setBackupTimeout(0); const writeStream = fs.createWriteStream(filePath); return new Promise((resolve, reject) => { cache .rewind() .pipe(writeStream) .on('finish', () => { clearTimeout(timeoutObject.timeout); resolve(); }) .on('error', (e: Error) => { clearTimeout(timeoutObject.timeout); console.warn(`write stream emitted error for ${filename}`, e); reject(e); }); }); } async function deleteOldestBackup() { const backupConfig = await importBackupConfig(); invariant(backupConfig, 'backupConfig should be non-null'); const files = await readdir(backupConfig.directory); let oldestFile; - for (let file of files) { + for (const file of files) { if (!file.endsWith('.sql.gz') || !file.startsWith('squadcal.')) { continue; } const stat = await lstat(`${backupConfig.directory}/${file}`); if (stat.isDirectory()) { continue; } if (!oldestFile || stat.mtime < oldestFile.mtime) { oldestFile = { file, mtime: stat.mtime }; } } if (!oldestFile) { return; } try { await unlink(`${backupConfig.directory}/${oldestFile.file}`); } catch (e) { // Check if it's already been deleted if (e.code !== 'ENOENT') { throw e; } } } export { backupDB }; diff --git a/server/src/deleters/thread-deleters.js b/server/src/deleters/thread-deleters.js index 21019183e..d8a48e61b 100644 --- a/server/src/deleters/thread-deleters.js +++ b/server/src/deleters/thread-deleters.js @@ -1,166 +1,166 @@ // @flow import bcrypt from 'twin-bcrypt'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { type ThreadDeletionRequest, type LeaveThreadResult, threadPermissions, } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { ServerError } from 'lib/utils/errors'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers'; import { fetchThreadPermissionsBlob } from '../fetchers/thread-permission-fetchers'; import { fetchUpdateInfoForThreadDeletion } from '../fetchers/update-fetchers'; import { rescindPushNotifs } from '../push/rescind'; import type { Viewer } from '../session/viewer'; async function deleteThread( viewer: Viewer, threadDeletionRequest: ThreadDeletionRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { threadID } = threadDeletionRequest; const [ permissionsBlob, [hashResult], { threadInfos: serverThreadInfos }, ] = await Promise.all([ fetchThreadPermissionsBlob(viewer, threadID), dbQuery(SQL`SELECT hash FROM users WHERE id = ${viewer.userID}`), fetchServerThreadInfos(SQL`t.id = ${threadID}`), ]); if (!permissionsBlob) { // This should only occur if the first request goes through but the client // never receives the response const [{ updateInfos }, fetchThreadInfoResult] = await Promise.all([ fetchUpdateInfoForThreadDeletion(viewer, threadID), hasMinCodeVersion(viewer.platformDetails, 62) ? undefined : fetchThreadInfos(viewer), ]); if (fetchThreadInfoResult) { const { threadInfos } = fetchThreadInfoResult; return { threadInfos, updatesResult: { newUpdates: updateInfos } }; } return { updatesResult: { newUpdates: updateInfos } }; } const hasPermission = permissionLookup( permissionsBlob, threadPermissions.DELETE_THREAD, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (hashResult.length === 0) { throw new ServerError('invalid_parameters'); } const row = hashResult[0]; if (!bcrypt.compareSync(threadDeletionRequest.accountPassword, row.hash)) { throw new ServerError('invalid_credentials'); } await rescindPushNotifs( SQL`n.thread = ${threadID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ); // TODO: if org, delete all descendant threads as well. make sure to warn user // TODO: handle descendant thread permission update correctly. // thread-permission-updaters should be used for descendant threads. const query = SQL` DELETE t, ic, d, id, e, ie, re, ire, mm, r, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids ic ON ic.id = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN memberships mm ON mm.thread = t.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON up.container = ms.id LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE t.id = ${threadID} `; const serverThreadInfo = serverThreadInfos[threadID]; const time = Date.now(); const updateDatas = []; - for (let memberInfo of serverThreadInfo.members) { + for (const memberInfo of serverThreadInfo.members) { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID: memberInfo.id, time, threadID, }); } const [{ viewerUpdates }] = await Promise.all([ createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'return' }), dbQuery(query), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function deleteInaccessibleThreads(): Promise { // A thread is considered "inaccessible" if it has no membership rows. Note // that membership rows exist whenever a user can see a thread, even if they // are not technically a member (in which case role=0) await dbQuery(SQL` DELETE t, i, m2, d, id, e, ie, re, ire, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids i ON i.id = t.id LEFT JOIN memberships m1 ON m1.thread = t.id AND m1.role > -1 LEFT JOIN memberships m2 ON m2.thread = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON up.container = ms.id LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE m1.thread IS NULL `); } export { deleteThread, deleteInaccessibleThreads }; diff --git a/server/src/fetchers/entry-fetchers.js b/server/src/fetchers/entry-fetchers.js index 3df767006..c134f6491 100644 --- a/server/src/fetchers/entry-fetchers.js +++ b/server/src/fetchers/entry-fetchers.js @@ -1,327 +1,327 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { filteredThreadIDs, filterExists, nonExcludeDeletedCalendarFilters, } from 'lib/selectors/calendar-filter-selectors'; import { rawEntryInfoWithinCalendarQuery } from 'lib/shared/entry-utils'; import type { CalendarQuery, FetchEntryInfosBase, DeltaEntryInfosResponse, RawEntryInfo, } from 'lib/types/entry-types'; import { calendarThreadFilterTypes } from 'lib/types/filter-types'; import type { HistoryRevisionInfo } from 'lib/types/history-types'; import { threadPermissions, type ThreadPermission, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, SQLStatement, mergeAndConditions, mergeOrConditions, } from '../database/database'; import type { Viewer } from '../session/viewer'; import { creationString } from '../utils/idempotent'; import { checkIfThreadIsBlocked } from './thread-permission-fetchers'; async function fetchEntryInfo( viewer: Viewer, entryID: string, ): Promise { const results = await fetchEntryInfosByID(viewer, [entryID]); if (results.length === 0) { return null; } return results[0]; } function rawEntryInfoFromRow(row: Object): RawEntryInfo { return { id: row.id.toString(), threadID: row.threadID.toString(), text: row.text, year: row.year, month: row.month, day: row.day, creationTime: row.creationTime, creatorID: row.creatorID.toString(), deleted: !!row.deleted, }; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchEntryInfosByID( viewer: Viewer, entryIDs: $ReadOnlyArray, ): Promise { if (entryIDs.length === 0) { return []; } const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.id IN (${entryIDs}) AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); return result.map(rawEntryInfoFromRow); } function sqlConditionForCalendarQuery( calendarQuery: CalendarQuery, ): ?SQLStatement { const { filters, startDate, endDate } = calendarQuery; const conditions = []; conditions.push(SQL`d.date BETWEEN ${startDate} AND ${endDate}`); const filterToThreadIDs = filteredThreadIDs(filters); if (filterToThreadIDs && filterToThreadIDs.size > 0) { conditions.push(SQL`d.thread IN (${[...filterToThreadIDs]})`); } else if (filterToThreadIDs) { // Filter to empty set means the result is empty return null; } else { conditions.push(SQL`m.role > 0`); } if (filterExists(filters, calendarThreadFilterTypes.NOT_DELETED)) { conditions.push(SQL`e.deleted = 0`); } return mergeAndConditions(conditions); } async function fetchEntryInfos( viewer: Viewer, calendarQueries: $ReadOnlyArray, ): Promise { const queryConditions = calendarQueries .map(sqlConditionForCalendarQuery) .filter((condition) => condition); if (queryConditions.length === 0) { return { rawEntryInfos: [] }; } const queryCondition = mergeOrConditions(queryConditions); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE AND `; query.append(queryCondition); query.append(SQL`ORDER BY e.creation_time DESC`); const [result] = await dbQuery(query); const rawEntryInfos = []; - for (let row of result) { + for (const row of result) { rawEntryInfos.push(rawEntryInfoFromRow(row)); } return { rawEntryInfos }; } async function checkThreadPermissionForEntry( viewer: Viewer, entryID: string, permission: ThreadPermission, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.permissions, t.id FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN threads t ON t.id = d.thread LEFT JOIN memberships m ON m.thread = t.id AND m.user = ${viewerID} WHERE e.id = ${entryID} `; const [result] = await dbQuery(query); if (result.length === 0) { return false; } const row = result[0]; if (row.id === null) { return false; } const threadIsBlocked = await checkIfThreadIsBlocked( viewer, row.id.toString(), permission, ); if (threadIsBlocked) { return false; } return permissionLookup(row.permissions, permission); } async function fetchEntryRevisionInfo( viewer: Viewer, entryID: string, ): Promise<$ReadOnlyArray> { const hasPermission = await checkThreadPermissionForEntry( viewer, entryID, threadPermissions.VISIBLE, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT r.id, u.username AS author, r.text, r.last_update AS lastUpdate, r.deleted, d.thread AS threadID, r.entry AS entryID FROM revisions r LEFT JOIN users u ON u.id = r.author LEFT JOIN entries e ON e.id = r.entry LEFT JOIN days d ON d.id = e.day WHERE r.entry = ${entryID} ORDER BY r.last_update DESC `; const [result] = await dbQuery(query); const revisions = []; - for (let row of result) { + for (const row of result) { revisions.push({ id: row.id.toString(), author: row.author, text: row.text, lastUpdate: row.lastUpdate, deleted: !!row.deleted, threadID: row.threadID.toString(), entryID: row.entryID.toString(), }); } return revisions; } // calendarQueries are the "difference" queries we get from subtracting the old // CalendarQuery from the new one. See calendarQueryDifference. // oldCalendarQuery is the old CalendarQuery. We make sure none of the returned // RawEntryInfos match the old CalendarQuery, so that only the difference is // returned. async function fetchEntriesForSession( viewer: Viewer, calendarQueries: $ReadOnlyArray, oldCalendarQuery: CalendarQuery, ): Promise { // If we're not including deleted entries, we will try and set deletedEntryIDs // so that the client can catch possibly stale deleted entryInfos let filterDeleted = null; - for (let calendarQuery of calendarQueries) { + for (const calendarQuery of calendarQueries) { const notDeletedFilterExists = filterExists( calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (filterDeleted === null) { filterDeleted = notDeletedFilterExists; } else { invariant( filterDeleted === notDeletedFilterExists, 'one of the CalendarQueries returned by calendarQueryDifference has ' + 'a NOT_DELETED filter but another does not: ' + JSON.stringify(calendarQueries), ); } } let calendarQueriesForFetch = calendarQueries; if (filterDeleted) { // Because in the filterDeleted case we still need the deleted RawEntryInfos // in order to construct deletedEntryIDs, we get rid of the NOT_DELETED // filters before passing the CalendarQueries to fetchEntryInfos. We will // filter out the deleted RawEntryInfos in a later step. calendarQueriesForFetch = calendarQueriesForFetch.map((calendarQuery) => ({ ...calendarQuery, filters: nonExcludeDeletedCalendarFilters(calendarQuery.filters), })); } const { rawEntryInfos } = await fetchEntryInfos( viewer, calendarQueriesForFetch, ); const entryInfosNotInOldQuery = rawEntryInfos.filter( (rawEntryInfo) => !rawEntryInfoWithinCalendarQuery(rawEntryInfo, oldCalendarQuery), ); let filteredRawEntryInfos = entryInfosNotInOldQuery; let deletedEntryIDs = []; if (filterDeleted) { filteredRawEntryInfos = entryInfosNotInOldQuery.filter( (rawEntryInfo) => !rawEntryInfo.deleted, ); deletedEntryIDs = entryInfosNotInOldQuery .filter((rawEntryInfo) => rawEntryInfo.deleted) .map((rawEntryInfo) => { const { id } = rawEntryInfo; invariant( id !== null && id !== undefined, 'serverID should be set in fetchEntryInfos result', ); return id; }); } return { rawEntryInfos: filteredRawEntryInfos, deletedEntryIDs, }; } async function fetchEntryInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.creator = ${viewerID} AND e.creation = ${creation} AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawEntryInfoFromRow(result[0]); } export { fetchEntryInfo, fetchEntryInfosByID, fetchEntryInfos, checkThreadPermissionForEntry, fetchEntryRevisionInfo, fetchEntriesForSession, fetchEntryInfoForLocalID, }; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index dc0733335..2331b1f43 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,588 +1,588 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils'; import { messageSpecs } from 'lib/shared/messages/message-specs'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, } from 'lib/types/message-types'; import { threadPermissions } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { PushInfo } from '../push/send'; import type { Viewer } from '../session/viewer'; import { creationString, localIDFromCreationString } from '../utils/idempotent'; import { mediaFromRow } from './upload-fetchers'; export type CollapsableNotifInfo = {| collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], |}; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; - for (let userID in pushInfo) { + for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; - for (let rawMessageInfo of pushInfo[userID].messageInfos) { + for (const rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; - for (let userID in usersToCollapseKeysToInfo) { + for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; - for (let collapseKey in collapseKeysToInfo) { + for (const collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } - for (let userID in usersToCollapseKeysToInfo) { + for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; - for (let collapseKey in collapseKeysToInfo) { + for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{| rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, |}>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); - for (let row of rows) { + for (const row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; - for (let messageRows of rowsByID.values()) { + for (const messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map((row) => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap, ): ?RawMessageInfo { const type = mostRecentRowType(rows); const messageSpec = messageSpecs[type]; if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { const media = rows.filter((row) => row.uploadID).map(mediaFromRow); const [row] = rows; const localID = localIDFromCreationString(viewer, row.creation); return messageSpec.rawMessageInfoFromRow(row, { media, derivedMessages, localID, }); } const row = assertSingleRow(rows); const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromRow, `unrecognized messageType ${type}`, ); return messageSpec.rawMessageInfoFromRow(row, { derivedMessages, localID }); } const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchMessageInfos( viewer: Viewer, criteria: ThreadSelectionCriteria, numberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` SELECT * FROM ( SELECT x.id, x.content, x.time, x.type, x.user AS creatorID, x.creation, x.subthread_permissions, x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra, @num := if( @thread = x.thread, if(@message = x.id, @num, @num + 1), 1 ) AS number, @message := x.id AS messageID, @thread := x.thread AS threadID FROM (SELECT @num := 0, @thread := '', @message := '') init JOIN ( SELECT m.id, m.thread, m.user, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC ) x ) y WHERE y.number <= ${numberPerThread} `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (const message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (const [threadID, messageCount] of threadToMessageCount) { // If there are fewer messages returned than the max for a given thread, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageCount < numberPerThread ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.TRUNCATED; } for (const rawMessageInfo of rawMessageInfos) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus === null || truncationStatus === undefined) { // If nothing was returned for a thread that was explicitly queried for, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else if (truncationStatus === messageTruncationStatus.TRUNCATED) { // If a cursor was specified for a given thread, then the result is // guaranteed to be contiguous with what the client has, and as such the // result should never be TRUNCATED truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) { const conditions = []; if (criteria.joinedThreads === true) { conditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { - for (let threadID in criteria.threadCursors) { + for (const threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`); } else { conditions.push(SQL`m.thread = ${threadID}`); } } } if (conditions.length === 0) { throw new ServerError('internal_error'); } return mergeOrConditions(conditions); } function threadSelectionCriteriaToInitialTruncationStatuses( criteria: ThreadSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { - for (let threadID in criteria.threadCursors) { + for (const threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: ThreadSelectionCriteria, currentAsOf: number, maxNumberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.time > ${currentAsOf} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (const message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; - for (let rawMessageInfo of rawMessageInfos) { + for (const rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise<$ReadOnlyMap> { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.sourceMessageID); } } const messagesByID = new Map(); if (requiredIDs.size === 0) { return messagesByID; } const result = await fetchMessageRowsByIDs([...requiredIDs]); const messages = parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { const { rawMessageInfo } = message; if (rawMessageInfo.id) { messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, }; diff --git a/server/src/fetchers/relationship-fetchers.js b/server/src/fetchers/relationship-fetchers.js index a10d436fc..29316f2bb 100644 --- a/server/src/fetchers/relationship-fetchers.js +++ b/server/src/fetchers/relationship-fetchers.js @@ -1,99 +1,99 @@ // @flow import _groupBy from 'lodash/fp/groupBy'; import { undirectedStatus, directedStatus } from 'lib/types/relationship-types'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; type RelationshipOperation = | 'delete_directed' | 'friend' | 'pending_friend' | 'know_of'; type UserRelationshipOperations = { [string]: $ReadOnlyArray, }; async function fetchFriendRequestRelationshipOperations( viewer: Viewer, userIDs: string[], ) { const query = SQL` SELECT user1, user2, status FROM relationships_directed WHERE (user1 IN (${userIDs}) AND user2 = ${viewer.userID}) OR (user1 = ${viewer.userID} AND user2 IN (${userIDs})) UNION SELECT user1, user2, status FROM relationships_undirected WHERE (user1 = ${viewer.userID} AND user2 IN (${userIDs})) OR (user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; const [result] = await dbQuery(query); const relationshipsByUserId = _groupBy( ({ user1, user2 }) => (user1.toString() === viewer.userID ? user2 : user1), result, ); const errors = {}; const userRelationshipOperations: UserRelationshipOperations = {}; for (const userID in relationshipsByUserId) { const relationships = relationshipsByUserId[userID]; const viewerBlockedTarget = relationships.some( (relationship) => relationship.status === directedStatus.BLOCKED && relationship.user1.toString() === viewer.userID, ); const targetBlockedViewer = relationships.some( (relationship) => relationship.status === directedStatus.BLOCKED && relationship.user2.toString() === viewer.userID, ); const friendshipExists = relationships.some( (relationship) => relationship.status === undirectedStatus.FRIEND, ); const viewerRequestedTargetFriendship = relationships.some( (relationship) => relationship.status === directedStatus.PENDING_FRIEND && relationship.user1.toString() === viewer.userID, ); const targetRequestedViewerFriendship = relationships.some( (relationship) => relationship.status === directedStatus.PENDING_FRIEND && relationship.user2.toString() === viewer.userID, ); const operations = []; if (targetBlockedViewer) { if (viewerBlockedTarget) { operations.push('delete_directed'); } const user_blocked = errors.user_blocked || []; errors.user_blocked = [...user_blocked, userID]; } else if (friendshipExists) { const already_friends = errors.already_friends || []; errors.already_friends = [...already_friends, userID]; } else if (targetRequestedViewerFriendship) { operations.push('friend', 'delete_directed'); } else if (!viewerRequestedTargetFriendship) { operations.push('pending_friend'); } userRelationshipOperations[userID] = operations; } - for (let userID of userIDs) { + for (const userID of userIDs) { if (!(userID in userRelationshipOperations)) { userRelationshipOperations[userID] = ['know_of', 'pending_friend']; } } return { errors, userRelationshipOperations }; } export { fetchFriendRequestRelationshipOperations }; diff --git a/server/src/fetchers/report-fetchers.js b/server/src/fetchers/report-fetchers.js index db595e378..ad5fb739c 100644 --- a/server/src/fetchers/report-fetchers.js +++ b/server/src/fetchers/report-fetchers.js @@ -1,105 +1,105 @@ // @flow import { isStaff } from 'lib/shared/user-utils'; import { type FetchErrorReportInfosResponse, type FetchErrorReportInfosRequest, type ReduxToolsImport, reportTypes, } from 'lib/types/report-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; async function fetchErrorReportInfos( viewer: Viewer, request: FetchErrorReportInfosRequest, ): Promise { if (!viewer.loggedIn || !isStaff(viewer.userID)) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT r.id, r.user, r.platform, r.report, r.creation_time, u.username FROM reports r LEFT JOIN users u ON u.id = r.user `; if (request.cursor) { query.append(SQL`WHERE r.id < ${request.cursor} `); } query.append(SQL`ORDER BY r.id DESC`); const [result] = await dbQuery(query); const reports = []; const userInfos = {}; - for (let row of result) { + for (const row of result) { const viewerID = row.user.toString(); let { platformDetails } = row.report; if (!platformDetails) { platformDetails = { platform: row.platform, codeVersion: row.report.codeVersion, stateVersion: row.report.stateVersion, }; } reports.push({ id: row.id.toString(), viewerID, platformDetails, creationTime: row.creation_time, }); if (row.username) { userInfos[viewerID] = { id: viewerID, username: row.username, }; } } return { reports, userInfos: values(userInfos) }; } async function fetchReduxToolsImport( viewer: Viewer, id: string, ): Promise { if (!viewer.loggedIn || !isStaff(viewer.userID)) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, report, creation_time FROM reports WHERE id = ${id} AND type = ${reportTypes.ERROR} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const row = result[0]; const _persist = row.report.preloadedState._persist ? row.report.preloadedState._persist : {}; const navState = row.report.currentState && row.report.currentState.navState ? row.report.currentState.navState : undefined; return { preloadedState: { ...row.report.preloadedState, _persist: { ..._persist, // Setting this to false disables redux-persist rehydrated: false, }, navState, frozen: true, }, payload: row.report.actions, }; } export { fetchErrorReportInfos, fetchReduxToolsImport }; diff --git a/server/src/fetchers/session-fetchers.js b/server/src/fetchers/session-fetchers.js index 34aa035ed..5f9e94f93 100644 --- a/server/src/fetchers/session-fetchers.js +++ b/server/src/fetchers/session-fetchers.js @@ -1,45 +1,45 @@ // @flow import type { CalendarQuery } from 'lib/types/entry-types'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; type CalendarSessionResult = {| userID: string, session: string, calendarQuery: CalendarQuery, |}; async function fetchActiveSessionsForThread( threadID: string, ): Promise { const query = SQL` SELECT s.id, s.user, s.query FROM memberships m LEFT JOIN sessions s ON s.user = m.user WHERE m.thread = ${threadID} AND m.role >= 0 AND s.query IS NOT NULL `; const [result] = await dbQuery(query); const filters = []; - for (let row of result) { + for (const row of result) { filters.push({ userID: row.user.toString(), session: row.id.toString(), calendarQuery: row.query, }); } return filters; } async function fetchOtherSessionsForViewer(viewer: Viewer): Promise { const query = SQL` SELECT id FROM sessions WHERE user = ${viewer.userID} AND id != ${viewer.session} `; const [result] = await dbQuery(query); return result.map((row) => row.id.toString()); } export { fetchActiveSessionsForThread, fetchOtherSessionsForViewer }; diff --git a/server/src/fetchers/thread-fetchers.js b/server/src/fetchers/thread-fetchers.js index 177e2952a..cd4d38a44 100644 --- a/server/src/fetchers/thread-fetchers.js +++ b/server/src/fetchers/thread-fetchers.js @@ -1,152 +1,152 @@ // @flow import { getAllThreadPermissions } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { RawThreadInfo, ServerThreadInfo } from 'lib/types/thread-types'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import type { Viewer } from '../session/viewer'; type FetchServerThreadInfosResult = {| threadInfos: { [id: string]: ServerThreadInfo }, |}; async function fetchServerThreadInfos( condition?: SQLStatement, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = SQL` SELECT t.id, t.name, t.parent_thread_id, t.color, t.description, t.type, t.creation_time, t.default_role, t.source_message, t.replies_count, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender FROM threads t LEFT JOIN ( SELECT thread, id, name, permissions FROM roles UNION SELECT id AS thread, 0 AS id, NULL AS name, NULL AS permissions FROM threads ) r ON r.thread = t.id LEFT JOIN memberships m ON m.role = r.id AND m.thread = t.id AND m.role >= 0 ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [result] = await dbQuery(query); const threadInfos = {}; - for (let row of result) { + for (const row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, name: row.name ? row.name : '', description: row.description ? row.description : '', color: row.color, creationTime: row.creation_time, parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, members: [], roles: {}, repliesCount: row.replies_count, }; } const sourceMessageID = row.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } const role = row.role.toString(); if (row.role && !threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: row.role_name, permissions: JSON.parse(row.role_permissions), isDefault: role === row.default_role.toString(), }; } if (row.user) { const userID = row.user.toString(); const allPermissions = getAllThreadPermissions(row.permissions, threadID); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: row.subscription, unread: row.role ? !!row.unread : null, isSender: !!row.sender, }); } } return { threadInfos }; } export type FetchThreadInfosResult = {| threadInfos: { [id: string]: RawThreadInfo }, |}; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatement, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const hasCodeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, 70); const threadInfos = {}; - for (let threadID in serverResult.threadInfos) { + for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { includeVisibilityRules: hasCodeVersionBelow70, filterMemberList: hasCodeVersionBelow70, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; - for (let row of result) { + for (const row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, }; diff --git a/server/src/fetchers/update-fetchers.js b/server/src/fetchers/update-fetchers.js index 2b6a1792d..ff28126a7 100644 --- a/server/src/fetchers/update-fetchers.js +++ b/server/src/fetchers/update-fetchers.js @@ -1,167 +1,167 @@ // @flow import invariant from 'invariant'; import type { CalendarQuery } from 'lib/types/entry-types'; import { type RawUpdateInfo, updateTypes, assertUpdateType, } from 'lib/types/update-types'; import { ServerError } from 'lib/utils/errors'; import type { ViewerInfo } from '../creators/update-creator'; import { type FetchUpdatesResult, fetchUpdateInfosWithRawUpdateInfos, } from '../creators/update-creator'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import type { Viewer } from '../session/viewer'; async function fetchUpdateInfosWithQuery( viewerInfo: ViewerInfo, query: SQLStatement, ): Promise { if (!viewerInfo.viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [result] = await dbQuery(query); const rawUpdateInfos = []; - for (let row of result) { + for (const row of result) { rawUpdateInfos.push(rawUpdateInfoFromRow(row)); } return await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, viewerInfo); } function fetchUpdateInfos( viewer: Viewer, currentAsOf: number, calendarQuery: CalendarQuery, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND time > ${currentAsOf} AND (updater IS NULL OR updater != ${viewer.session}) AND (target IS NULL OR target = ${viewer.session}) ORDER BY time ASC `; return fetchUpdateInfosWithQuery({ viewer, calendarQuery }, query); } function rawUpdateInfoFromRow(row: Object): RawUpdateInfo { const type = assertUpdateType(row.type); if (type === updateTypes.DELETE_ACCOUNT) { const content = JSON.parse(row.content); return { type: updateTypes.DELETE_ACCOUNT, id: row.id.toString(), time: row.time, deletedUserID: content.deletedUserID, }; } else if (type === updateTypes.UPDATE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: row.id.toString(), time: row.time, threadID, unread, }; } else if (type === updateTypes.DELETE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.DELETE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.JOIN_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.JOIN_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken } = JSON.parse(row.content); return { type: updateTypes.BAD_DEVICE_TOKEN, id: row.id.toString(), time: row.time, deviceToken, }; } else if (type === updateTypes.UPDATE_ENTRY) { const { entryID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_ENTRY, id: row.id.toString(), time: row.time, entryID, }; } else if (type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id: row.id.toString(), time: row.time, }; } else if (type === updateTypes.UPDATE_USER) { const content = JSON.parse(row.content); return { type: updateTypes.UPDATE_USER, id: row.id.toString(), time: row.time, updatedUserID: content.updatedUserID, }; } invariant(false, `unrecognized updateType ${type}`); } const entryIDExtractString = '$.entryID'; function fetchUpdateInfoForEntryUpdate( viewer: Viewer, entryID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.UPDATE_ENTRY} AND JSON_EXTRACT(content, ${entryIDExtractString}) = ${entryID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } const threadIDExtractString = '$.threadID'; function fetchUpdateInfoForThreadDeletion( viewer: Viewer, threadID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.DELETE_THREAD} AND JSON_EXTRACT(content, ${threadIDExtractString}) = ${threadID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } export { fetchUpdateInfos, fetchUpdateInfoForEntryUpdate, fetchUpdateInfoForThreadDeletion, }; diff --git a/server/src/fetchers/user-fetchers.js b/server/src/fetchers/user-fetchers.js index c25b6f3c1..f0c6c51ba 100644 --- a/server/src/fetchers/user-fetchers.js +++ b/server/src/fetchers/user-fetchers.js @@ -1,235 +1,235 @@ // @flow import { undirectedStatus, directedStatus, userRelationshipStatus, } from 'lib/types/relationship-types'; import type { UserInfos, CurrentUserInfo, LoggedInUserInfo, GlobalUserInfo, } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; async function fetchUserInfos( userIDs: string[], ): Promise<{ [id: string]: GlobalUserInfo }> { if (userIDs.length <= 0) { return {}; } const query = SQL` SELECT id, username FROM users WHERE id IN (${userIDs}) `; const [result] = await dbQuery(query); const userInfos = {}; - for (let row of result) { + for (const row of result) { const id = row.id.toString(); userInfos[id] = { id, username: row.username, }; } - for (let userID of userIDs) { + for (const userID of userIDs) { if (!userInfos[userID]) { userInfos[userID] = { id: userID, username: null, }; } } return userInfos; } async function fetchKnownUserInfos( viewer: Viewer, userIDs?: $ReadOnlyArray, ): Promise { if (!viewer.loggedIn) { return {}; } if (userIDs && userIDs.length === 0) { return {}; } const query = SQL` SELECT ru.user1, ru.user2, ru.status AS undirected_status, rd1.status AS user1_directed_status, rd2.status AS user2_directed_status, u.username FROM relationships_undirected ru LEFT JOIN relationships_directed rd1 ON rd1.user1 = ru.user1 AND rd1.user2 = ru.user2 LEFT JOIN relationships_directed rd2 ON rd2.user1 = ru.user2 AND rd2.user2 = ru.user1 LEFT JOIN users u ON u.id != ${viewer.userID} AND (u.id = ru.user1 OR u.id = ru.user2) `; if (userIDs) { query.append(SQL` WHERE (ru.user1 = ${viewer.userID} AND ru.user2 IN (${userIDs})) OR (ru.user1 IN (${userIDs}) AND ru.user2 = ${viewer.userID}) `); } else { query.append(SQL` WHERE ru.user1 = ${viewer.userID} OR ru.user2 = ${viewer.userID} `); } query.append(SQL` UNION SELECT id AS user1, NULL AS user2, NULL AS undirected_status, NULL AS user1_directed_status, NULL AS user2_directed_status, username FROM users WHERE id = ${viewer.userID} `); const [result] = await dbQuery(query); const userInfos = {}; for (const row of result) { const user1 = row.user1.toString(); const user2 = row.user2 ? row.user2.toString() : null; const id = user1 === viewer.userID && user2 ? user2 : user1; const userInfo = { id, username: row.username, }; if (!user2) { userInfos[id] = userInfo; continue; } let viewerDirectedStatus; let targetDirectedStatus; if (user1 === viewer.userID) { viewerDirectedStatus = row.user1_directed_status; targetDirectedStatus = row.user2_directed_status; } else { viewerDirectedStatus = row.user2_directed_status; targetDirectedStatus = row.user1_directed_status; } const viewerBlockedTarget = viewerDirectedStatus === directedStatus.BLOCKED; const targetBlockedViewer = targetDirectedStatus === directedStatus.BLOCKED; const friendshipExists = row.undirected_status === undirectedStatus.FRIEND; const viewerRequestedTargetFriendship = viewerDirectedStatus === directedStatus.PENDING_FRIEND; const targetRequestedViewerFriendship = targetDirectedStatus === directedStatus.PENDING_FRIEND; let relationshipStatus; if (viewerBlockedTarget && targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BOTH_BLOCKED; } else if (targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BLOCKED_VIEWER; } else if (viewerBlockedTarget) { relationshipStatus = userRelationshipStatus.BLOCKED_BY_VIEWER; } else if (friendshipExists) { relationshipStatus = userRelationshipStatus.FRIEND; } else if (targetRequestedViewerFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_RECEIVED; } else if (viewerRequestedTargetFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_SENT; } userInfos[id] = userInfo; if (relationshipStatus) { userInfos[id].relationshipStatus = relationshipStatus; } if (relationshipStatus && !row.username) { console.warn( `user ${viewer.userID} has ${relationshipStatus} relationship with ` + `anonymous user ${id}`, ); } } return userInfos; } async function verifyUserIDs( userIDs: $ReadOnlyArray, ): Promise { if (userIDs.length === 0) { return []; } const query = SQL`SELECT id FROM users WHERE id IN (${userIDs})`; const [result] = await dbQuery(query); return result.map((row) => row.id.toString()); } async function verifyUserOrCookieIDs( ids: $ReadOnlyArray, ): Promise { if (ids.length === 0) { return []; } const query = SQL` SELECT id FROM users WHERE id IN (${ids}) UNION SELECT id FROM cookies WHERE id IN (${ids}) `; const [result] = await dbQuery(query); return result.map((row) => row.id.toString()); } async function fetchCurrentUserInfo(viewer: Viewer): Promise { if (!viewer.loggedIn) { return { id: viewer.cookieID, anonymous: true }; } const currentUserInfos = await fetchLoggedInUserInfos([viewer.userID]); if (currentUserInfos.length === 0) { throw new ServerError('unknown_error'); } return currentUserInfos[0]; } async function fetchLoggedInUserInfos( userIDs: $ReadOnlyArray, ): Promise { const query = SQL` SELECT id, username, email, email_verified FROM users WHERE id IN (${userIDs}) `; const [result] = await dbQuery(query); return result.map((row) => ({ id: row.id.toString(), username: row.username, email: row.email, emailVerified: !!row.email_verified, })); } async function fetchAllUserIDs(): Promise { const query = SQL`SELECT id FROM users`; const [result] = await dbQuery(query); return result.map((row) => row.id.toString()); } async function fetchUsername(id: string): Promise { const query = SQL`SELECT username FROM users WHERE id = ${id}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.username; } export { fetchUserInfos, verifyUserIDs, verifyUserOrCookieIDs, fetchCurrentUserInfo, fetchLoggedInUserInfos, fetchAllUserIDs, fetchUsername, fetchKnownUserInfos, }; diff --git a/server/src/models/verification.js b/server/src/models/verification.js index 2dfd47fac..d14ee4c49 100644 --- a/server/src/models/verification.js +++ b/server/src/models/verification.js @@ -1,164 +1,164 @@ // @flow import crypto from 'crypto'; import bcrypt from 'twin-bcrypt'; import { updateTypes } from 'lib/types/update-types'; import { type VerifyField, verifyField, assertVerifyField, type ServerSuccessfulVerificationResult, } from 'lib/types/verify-types'; import { ServerError } from 'lib/utils/errors'; import createIDs from '../creators/id-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { Viewer } from '../session/viewer'; const day = 24 * 60 * 60 * 1000; // in ms const verifyCodeLifetimes = { [verifyField.EMAIL]: day * 30, [verifyField.RESET_PASSWORD]: day, }; async function createVerificationCode( userID: string, field: VerifyField, ): Promise { const code = crypto.randomBytes(4).toString('hex'); const hash = bcrypt.hashSync(code); const [id] = await createIDs('verifications', 1); const time = Date.now(); const row = [id, userID, field, hash, time]; const query = SQL` INSERT INTO verifications(id, user, field, hash, creation_time) VALUES ${[row]} `; await dbQuery(query); return `${code}${parseInt(id, 10).toString(16)}`; } type CodeVerification = {| userID: string, field: VerifyField, |}; async function verifyCode(hex: string): Promise { const code = hex.substr(0, 8); const id = parseInt(hex.substr(8), 16); if (isNaN(id)) { throw new ServerError('invalid_code'); } const query = SQL` SELECT hash, user, field, creation_time FROM verifications WHERE id = ${id} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_code'); } const row = result[0]; if (!bcrypt.compareSync(code, row.hash)) { throw new ServerError('invalid_code'); } const field = assertVerifyField(row.field); const verifyCodeLifetime = verifyCodeLifetimes[field]; if ( verifyCodeLifetime && row.creation_time + verifyCodeLifetime <= Date.now() ) { // Code is expired. Delete it... const deleteQuery = SQL` DELETE v, i FROM verifications v LEFT JOIN ids i ON i.id = v.id WHERE v.id = ${id} `; await dbQuery(deleteQuery); throw new ServerError('invalid_code'); } return { userID: row.user.toString(), field, }; } // Call this function after a successful verification async function clearVerifyCodes(result: CodeVerification) { const deleteQuery = SQL` DELETE v, i FROM verifications v LEFT JOIN ids i ON i.id = v.id WHERE v.user = ${result.userID} and v.field = ${result.field} `; await dbQuery(deleteQuery); } async function handleCodeVerificationRequest( viewer: Viewer, code: string, ): Promise { const { userID, field } = await verifyCode(code); if (field === verifyField.EMAIL) { const query = SQL`UPDATE users SET email_verified = 1 WHERE id = ${userID}`; await dbQuery(query); const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); return { success: true, field: verifyField.EMAIL }; } else if (field === verifyField.RESET_PASSWORD) { const usernameQuery = SQL`SELECT username FROM users WHERE id = ${userID}`; const [usernameResult] = await dbQuery(usernameQuery); if (usernameResult.length === 0) { throw new ServerError('invalid_code'); } const usernameRow = usernameResult[0]; return { success: true, field: verifyField.RESET_PASSWORD, username: usernameRow.username, }; } throw new ServerError('invalid_code'); } async function deleteExpiredVerifications(): Promise { const creationTimeConditions = []; - for (let field in verifyCodeLifetimes) { + for (const field in verifyCodeLifetimes) { const lifetime = verifyCodeLifetimes[field]; const earliestInvalid = Date.now() - lifetime; creationTimeConditions.push( SQL`v.field = ${field} AND v.creation_time <= ${earliestInvalid}`, ); } const creationTimeClause = mergeOrConditions(creationTimeConditions); const query = SQL` DELETE v, i FROM verifications v LEFT JOIN ids i ON i.id = v.id WHERE `; query.append(creationTimeClause); await dbQuery(query); } export { createVerificationCode, verifyCode, clearVerifyCodes, handleCodeVerificationRequest, deleteExpiredVerifications, }; diff --git a/server/src/push/rescind.js b/server/src/push/rescind.js index 4718238e3..1577b4b5d 100644 --- a/server/src/push/rescind.js +++ b/server/src/push/rescind.js @@ -1,192 +1,192 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import { threadSubscriptions } from 'lib/types/subscription-types'; import { threadPermissions } from 'lib/types/thread-types'; import { promiseAll } from 'lib/utils/promises'; import createIDs from '../creators/id-creator'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import { apnPush, fcmPush } from './utils'; // Returns list of deviceTokens that have been updated async function rescindPushNotifs( notifCondition: SQLStatement, inputCountCondition?: SQLStatement, ): Promise { const notificationExtractString = `$.${threadSubscriptions.home}`; const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const fetchQuery = SQL` SELECT n.id, n.user, n.thread, n.message, n.delivery, n.collapse_key, COUNT( `; fetchQuery.append(inputCountCondition ? inputCountCondition : SQL`m.thread`); fetchQuery.append(SQL` ) AS unread_count FROM notifications n LEFT JOIN memberships m ON m.user = n.user AND m.last_message > m.last_read_message AND m.role > 0 AND JSON_EXTRACT(subscription, ${notificationExtractString}) AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) WHERE n.rescinded = 0 AND `); fetchQuery.append(notifCondition); fetchQuery.append(SQL` GROUP BY n.id, m.user`); const [fetchResult] = await dbQuery(fetchQuery); const deliveryPromises = {}; const notifInfo = {}; const rescindedIDs = []; const receivingDeviceTokens = []; for (const row of fetchResult) { const deliveries = Array.isArray(row.delivery) ? row.delivery : [row.delivery]; const id = row.id.toString(); const threadID = row.thread.toString(); notifInfo[id] = { userID: row.user.toString(), threadID, messageID: row.message.toString(), }; - for (let delivery of deliveries) { + for (const delivery of deliveries) { if (delivery.iosID && delivery.iosDeviceTokens) { // Old iOS const notification = prepareIOSNotification( delivery.iosID, row.unread_count, ); deliveryPromises[id] = apnPush(notification, delivery.iosDeviceTokens); receivingDeviceTokens.push(...delivery.iosDeviceTokens); } else if (delivery.androidID) { // Old Android const notification = prepareAndroidNotification( row.collapse_key ? row.collapse_key : id, row.unread_count, threadID, null, ); deliveryPromises[id] = fcmPush( notification, delivery.androidDeviceTokens, null, ); receivingDeviceTokens.push(...delivery.androidDeviceTokens); } else if (delivery.deviceType === 'ios') { // New iOS const { iosID, deviceTokens } = delivery; const notification = prepareIOSNotification(iosID, row.unread_count); deliveryPromises[id] = apnPush(notification, deviceTokens); receivingDeviceTokens.push(...deviceTokens); } else if (delivery.deviceType === 'android') { // New Android const { deviceTokens, codeVersion } = delivery; const notification = prepareAndroidNotification( row.collapse_key ? row.collapse_key : id, row.unread_count, threadID, codeVersion, ); deliveryPromises[id] = fcmPush(notification, deviceTokens, null); receivingDeviceTokens.push(...deviceTokens); } } rescindedIDs.push(row.id); } const numRescinds = Object.keys(deliveryPromises).length; const promises = [promiseAll(deliveryPromises)]; if (numRescinds > 0) { promises.push(createIDs('notifications', numRescinds)); } if (rescindedIDs.length > 0) { const rescindQuery = SQL` UPDATE notifications SET rescinded = 1 WHERE id IN (${rescindedIDs}) `; promises.push(dbQuery(rescindQuery)); } const [deliveryResults, dbIDs] = await Promise.all(promises); const newNotifRows = []; if (numRescinds > 0) { invariant(dbIDs, 'dbIDs should be set'); for (const rescindedID in deliveryResults) { const delivery = {}; delivery.source = 'rescind'; delivery.rescindedID = rescindedID; const { errors } = deliveryResults[rescindedID]; if (errors) { delivery.errors = errors; } const dbID = dbIDs.shift(); const { userID, threadID, messageID } = notifInfo[rescindedID]; newNotifRows.push([ dbID, userID, threadID, messageID, null, JSON.stringify([delivery]), 1, ]); } } if (newNotifRows.length > 0) { const insertQuery = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${newNotifRows} `; await dbQuery(insertQuery); } return [...new Set(receivingDeviceTokens)]; } function prepareIOSNotification( iosID: string, unreadCount: number, ): apn.Notification { const notification = new apn.Notification(); notification.contentAvailable = true; notification.badge = unreadCount; notification.topic = 'org.squadcal.app'; notification.payload = { managedAps: { action: 'CLEAR', notificationId: iosID, }, }; return notification; } function prepareAndroidNotification( notifID: string, unreadCount: number, threadID: string, codeVersion: ?number, ): Object { if (!codeVersion || codeVersion < 31) { return { data: { badge: unreadCount.toString(), custom_notification: JSON.stringify({ rescind: 'true', notifID, }), }, }; } return { data: { badge: unreadCount.toString(), rescind: 'true', rescindID: notifID, threadID, }, }; } export { rescindPushNotifs }; diff --git a/server/src/push/send.js b/server/src/push/send.js index 510a97c2f..573bccd59 100644 --- a/server/src/push/send.js +++ b/server/src/push/send.js @@ -1,799 +1,799 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import _flow from 'lodash/fp/flow'; import _mapValues from 'lodash/fp/mapValues'; import _pickBy from 'lodash/fp/pickBy'; import uuidv4 from 'uuid/v4'; import { oldValidUsernameRegex } from 'lib/shared/account-utils'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils'; import { messageSpecs } from 'lib/shared/messages/message-specs'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils'; import type { DeviceType } from 'lib/types/device-types'; import { type RawMessageInfo, type MessageInfo, messageTypes, } from 'lib/types/message-types'; import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { promiseAll } from 'lib/utils/promises'; import createIDs from '../creators/id-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers'; import { fetchUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { apnPush, fcmPush, getUnreadCounts } from './utils'; type Device = {| +deviceType: DeviceType, +deviceToken: string, +codeVersion: ?number, |}; type PushUserInfo = {| +devices: Device[], +messageInfos: RawMessageInfo[], |}; type Delivery = IOSDelivery | AndroidDelivery | {| collapsedInto: string |}; type NotificationRow = {| +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], |}; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); - for (let userID in usersToCollapsableNotifInfo) { + for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy((threadInfo) => threadInfo), )(serverThreadInfos); - for (let notifInfo of usersToCollapsableNotifInfo[userID]) { + for (const notifInfo of usersToCollapsableNotifInfo[userID]) { const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; - for (let newRawMessageInfo of notifInfo.newMessageInfos) { + for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { continue; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [ firstNewMessageInfo, ...remainingNewMessageInfos ] = newMessageInfos; const threadID = firstNewMessageInfo.threadID; const threadInfo = threadInfos[threadID]; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && firstNewMessageInfo.type === messageTypes.TEXT && new RegExp(`\\B@${username}\\b`, 'i').test(firstNewMessageInfo.text); if (!updateBadge && !displayBanner && !userWasMentioned) { continue; } const badgeOnly = !displayBanner && !userWasMentioned; const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byDeviceType = getDevicesByDeviceType(pushInfo[userID].devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'ios', codeVersion }, ); const notification = prepareIOSNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], ); deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'android', codeVersion }, ); const notification = prepareAndroidNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], dbID, codeVersion, ); deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } - for (let newMessageInfo of remainingNewMessageInfos) { + for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); notifications.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } } } const cleanUpPromises = []; if (dbIDs.length > 0) { const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; cleanUpPromises.push(dbQuery(query)); } const [deliveryResults] = await Promise.all([ Promise.all(deliveryPromises), Promise.all(cleanUpPromises), ]); await saveNotifResults(deliveryResults, notifications, true); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } const promises = {}; // These threadInfos won't have currentUser set promises.threadResult = fetchServerThreadInfos( SQL`t.id IN (${[...threadIDs]})`, ); if (threadWithChangedNamesToMessages.size > 0) { const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); promises.oldNames = dbQuery(oldNameQuery); } const { threadResult, oldNames } = await promiseAll(promises); const serverThreadInfos = threadResult.threadInfos; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID].name = row.name; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { [threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; - for (let userID in pushInfo) { + for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } function getDevicesByDeviceType( devices: Device[], ): Map>> { const byDeviceType = new Map(); - for (let device of devices) { + for (const device of devices) { let innerMap = byDeviceType.get(device.deviceType); if (!innerMap) { innerMap = new Map(); byDeviceType.set(device.deviceType, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; let innerMostSet = innerMap.get(codeVersion); if (!innerMostSet) { innerMostSet = new Set(); innerMap.set(codeVersion, innerMostSet); } innerMostSet.add(device.deviceToken); } return byDeviceType; } function prepareIOSNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, ): apn.Notification { const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = 'org.squadcal.app'; const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); if (!badgeOnly) { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadInfo.id; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadInfo.id; notification.payload.messageInfos = JSON.stringify(newRawMessageInfos); if (collapseKey) { notification.collapseId = collapseKey; } return notification; } function prepareAndroidNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, dbID: string, codeVersion: number, ): Object { const notifID = collapseKey ? collapseKey : dbID; const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); const messageInfos = JSON.stringify(newRawMessageInfos); if (badgeOnly && codeVersion < 69) { // Older Android clients don't look at badgeOnly, so if we sent them the // full payload they would treat it as a normal notif. Instead we will // send them this payload that is missing an ID, which will prevent the // system notif from being generated, but still allow for in-app notifs // and badge updating. return { data: { badge: unreadCount.toString(), ...rest, threadID: threadInfo.id, messageInfos, }, }; } else if (codeVersion < 31) { return { data: { badge: unreadCount.toString(), custom_notification: JSON.stringify({ channel: 'default', body: merged, badgeCount: unreadCount, id: notifID, priority: 'high', sound: 'default', icon: 'notif_icon', threadID: threadInfo.id, messageInfos, click_action: 'fcm.ACTION.HELLO', }), }, }; } return { data: { badge: unreadCount.toString(), ...rest, id: notifID, threadID: threadInfo.id, messageInfos, badgeOnly: badgeOnly ? '1' : '0', }, }; } type NotificationInfo = | {| +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, |} | {| +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, |}; type IOSDelivery = {| source: $PropertyType, deviceType: 'ios', iosID: string, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type IOSResult = {| info: NotificationInfo, delivery: IOSDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendIOSNotification( notification: apn.Notification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const response = await apnPush(notification, deviceTokens); const delivery: IOSDelivery = { source: notificationInfo.source, deviceType: 'ios', iosID: notification.id, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: IOSResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type AndroidDelivery = {| source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type AndroidResult = {| info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendAndroidNotification( notification: Object, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const response = await fcmPush(notification, deviceTokens, collapseKey); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source: notificationInfo.source, deviceType: 'android', androidIDs, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type InvalidToken = {| +userID: string, +tokens: $ReadOnlyArray, |}; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( (invalidTokenUser) => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map(); - for (let row of result) { + for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises = []; - for (let entry of userCookiePairsToInvalidDeviceTokens) { + for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map((deviceToken) => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', excludeDeviceTokens: $ReadOnlyArray, ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } if (excludeDeviceTokens.length > 0) { deviceTokenQuery.append( SQL`AND device_token NOT IN (${excludeDeviceTokens}) `, ); } const [unreadCounts, [deviceTokenResult], [dbID]] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map((row) => ({ deviceType: row.platform, deviceToken: row.device_token, codeVersion: row.versions?.codeVersion, })); const byDeviceType = getDevicesByDeviceType(devices); const deliveryPromises = []; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notification = new apn.Notification(); notification.topic = 'org.squadcal.app'; notification.badge = unreadCount; notification.pushType = 'alert'; deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notification = { data: { badge: unreadCount.toString() } }; deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, updateBadgeCount }; diff --git a/server/src/push/utils.js b/server/src/push/utils.js index a0253e711..cd1318b4d 100644 --- a/server/src/push/utils.js +++ b/server/src/push/utils.js @@ -1,212 +1,212 @@ // @flow import apn from '@parse/node-apn'; import fcmAdmin from 'firebase-admin'; import invariant from 'invariant'; import { threadSubscriptions } from 'lib/types/subscription-types'; import { threadPermissions } from 'lib/types/thread-types'; import { dbQuery, SQL } from '../database/database'; let cachedAPNProvider = undefined; async function getAPNProvider() { if (cachedAPNProvider !== undefined) { return cachedAPNProvider; } try { // $FlowFixMe const apnConfig = await import('../../secrets/apn_config'); if (cachedAPNProvider === undefined) { cachedAPNProvider = new apn.Provider(apnConfig.default); } } catch { if (cachedAPNProvider === undefined) { cachedAPNProvider = null; } } return cachedAPNProvider; } let fcmAppInitialized = undefined; async function initializeFCMApp() { if (fcmAppInitialized !== undefined) { return fcmAppInitialized; } try { // $FlowFixMe const fcmConfig = await import('../../secrets/fcm_config'); if (fcmAppInitialized === undefined) { fcmAppInitialized = true; fcmAdmin.initializeApp({ credential: fcmAdmin.credential.cert(fcmConfig.default), }); } } catch { if (cachedAPNProvider === undefined) { fcmAppInitialized = false; } } return fcmAppInitialized; } function endFirebase() { fcmAdmin.apps?.forEach((app) => app?.delete()); } async function endAPNs() { const apnProvider = await getAPNProvider(); apnProvider?.shutdown(); } const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; async function apnPush( notification: apn.Notification, deviceTokens: $ReadOnlyArray, ) { const apnProvider = await getAPNProvider(); if (!apnProvider && process.env.NODE_ENV === 'dev') { console.log('no server/secrets/apn_config.json so ignoring notifs'); return { success: true }; } invariant(apnProvider, 'server/secrets/apn_config.json should exist'); const result = await apnProvider.send(notification, deviceTokens); const errors = []; const invalidTokens = []; - for (let error of result.failed) { + for (const error of result.failed) { errors.push(error); if ( error.status == apnTokenInvalidationErrorCode || (error.status == apnBadRequestErrorCode && error.response.reason === apnBadTokenErrorString) ) { invalidTokens.push(error.device); } } if (invalidTokens.length > 0) { return { errors, invalidTokens }; } else if (errors.length > 0) { return { errors }; } else { return { success: true }; } } async function fcmPush( notification: Object, deviceTokens: $ReadOnlyArray, collapseKey: ?string, ) { const initialized = await initializeFCMApp(); if (!initialized && process.env.NODE_ENV === 'dev') { console.log('no server/secrets/fcm_config.json so ignoring notifs'); return { success: true }; } invariant(initialized, 'server/secrets/fcm_config.json should exist'); const options: Object = { priority: 'high', }; if (collapseKey) { options.collapseKey = collapseKey; } // firebase-admin is extremely barebones and has a lot of missing or poorly // thought-out functionality. One of the issues is that if you send a // multicast messages and one of the device tokens is invalid, the resultant // won't explain which of the device tokens is invalid. So we're forced to // avoid the multicast functionality and call it once per deviceToken. const promises = []; - for (let deviceToken of deviceTokens) { + for (const deviceToken of deviceTokens) { promises.push(fcmSinglePush(notification, deviceToken, options)); } const pushResults = await Promise.all(promises); const errors = []; const ids = []; const invalidTokens = []; for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; - for (let error of pushResult.errors) { + for (const error of pushResult.errors) { errors.push(error); if (fcmTokenInvalidationErrors.has(error.errorInfo.code)) { invalidTokens.push(deviceTokens[i]); } } - for (let id of pushResult.fcmIDs) { + for (const id of pushResult.fcmIDs) { ids.push(id); } } const result = {}; if (ids.length > 0) { result.fcmIDs = ids; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return result; } async function fcmSinglePush( notification: Object, deviceToken: string, options: Object, ) { try { const deliveryResult = await fcmAdmin .messaging() .sendToDevice(deviceToken, notification, options); const errors = []; const ids = []; - for (let fcmResult of deliveryResult.results) { + for (const fcmResult of deliveryResult.results) { if (fcmResult.error) { errors.push(fcmResult.error); } else if (fcmResult.messageId) { ids.push(fcmResult.messageId); } } return { fcmIDs: ids, errors }; } catch (e) { return { fcmIDs: [], errors: [e] }; } } async function getUnreadCounts( userIDs: string[], ): Promise<{ [userID: string]: number }> { const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const notificationExtractString = `$.${threadSubscriptions.home}`; const query = SQL` SELECT user, COUNT(thread) AS unread_count FROM memberships WHERE user IN (${userIDs}) AND last_message > last_read_message AND role > 0 AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) AND JSON_EXTRACT(subscription, ${notificationExtractString}) GROUP BY user `; const [result] = await dbQuery(query); const usersToUnreadCounts = {}; - for (let row of result) { + for (const row of result) { usersToUnreadCounts[row.user.toString()] = row.unread_count; } - for (let userID of userIDs) { + for (const userID of userIDs) { if (usersToUnreadCounts[userID] === undefined) { usersToUnreadCounts[userID] = 0; } } return usersToUnreadCounts; } export { apnPush, fcmPush, getUnreadCounts, endFirebase, endAPNs }; diff --git a/server/src/responders/user-responders.js b/server/src/responders/user-responders.js index d7b656baf..25c4ac217 100644 --- a/server/src/responders/user-responders.js +++ b/server/src/responders/user-responders.js @@ -1,324 +1,324 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, AccessRequest, } from 'lib/types/account-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { AccountUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import createAccount from '../creators/account-creator'; import { dbQuery, SQL } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { sendAccessRequestEmailToAshoat } from '../emails/access-request'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, } from '../updaters/account-updaters'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { validateInput, tShape, tPlatformDetails, tDeviceType, tPassword, } from '../utils/validation-utils'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(t.String), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function accountUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: AccountUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.String, }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: tPassword, }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.String, password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } const logInRequestInputValidator = tShape({ usernameOrEmail: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const userQuery = SQL` SELECT id, hash, username, email, email_verified FROM users WHERE LCASE(username) = LCASE(${request.usernameOrEmail}) OR LCASE(email) = LCASE(${request.usernameOrEmail}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; - for (let watchedThreadID of request.watchedIDs) { + for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo: { id, username: userRow.username, email: userRow.email, emailVerified: !!userRow.email_verified, }, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const accessRequestInputValidator = tShape({ email: t.String, platform: tDeviceType, }); async function requestAccessResponder( viewer: Viewer, input: any, ): Promise { const request: AccessRequest = input; await validateInput(viewer, accessRequestInputValidator, request); await sendAccessRequestEmailToAshoat(request); } export { userSubscriptionUpdateResponder, accountUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, passwordUpdateResponder, requestAccessResponder, }; diff --git a/server/src/responders/website-responders.js b/server/src/responders/website-responders.js index 8d9b9d994..f1346f97d 100644 --- a/server/src/responders/website-responders.js +++ b/server/src/responders/website-responders.js @@ -1,331 +1,331 @@ // @flow import html from 'common-tags/lib/html'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _keyBy from 'lodash/fp/keyBy'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { Provider } from 'react-redux'; import { Route, StaticRouter } from 'react-router'; import { createStore, type Store } from 'redux'; import { promisify } from 'util'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer'; import { freshMessageStore } from 'lib/reducers/message-reducer'; import { mostRecentReadThread } from 'lib/selectors/thread-selectors'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { currentDateInTimeZone } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import App from 'web/dist/app.build.cjs'; import { reducer } from 'web/redux/redux-setup'; import type { AppState, Action } from 'web/redux/redux-setup'; import getTitle from 'web/title/getTitle'; import { navInfoFromURL } from 'web/url-utils'; import urlFacts from '../../facts/url'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { handleCodeVerificationRequest } from '../models/verification'; import { setNewSession } from '../session/cookies'; import { Viewer } from '../session/viewer'; import { streamJSON, waitForStream } from '../utils/json-stream'; const { basePath, baseDomain } = urlFacts; const { renderToNodeStream } = ReactDOMServer; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const access = promisify(fs.access); const googleFontsURL = 'https://fonts.googleapis.com/css?family=Open+Sans:300,600%7CAnaheim'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = {| jsURL: string, fontsURL: string, cssInclude: string |}; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'dev') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', }; return assetInfo; } // $FlowFixMe compiled/assets.json doesn't always exist const { default: assets } = await import('../../compiled/assets'); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, }; return assetInfo; } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const threadSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const serverVerificationResultPromise = handleVerificationRequest( viewer, initialNavInfo.verify, ); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const { threadInfos } = await threadInfoPromise; return { threadInfos, inconsistencyReports: [] }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, ] = await Promise.all([threadInfoPromise, messageInfoPromise]); return freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); })(); const entryStorePromise = (async () => { const { rawEntryInfos } = await entryInfoPromise; return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, inconsistencyReports: [], }; })(); const userStorePromise = (async () => { const userInfos = await userInfoPromise; return { userInfos, inconsistencyReports: [] }; })(); const navInfoPromise = (async () => { const [{ threadInfos }, messageStore] = await Promise.all([ threadInfoPromise, messageStorePromise, ]); - let finalNavInfo = initialNavInfo; + const finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentReadThread(messageStore, threadInfos); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } return finalNavInfo; })(); const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const statePromises = { navInfo: navInfoPromise, currentUserInfo: currentUserInfoPromise, sessionID: sessionIDPromise, serverVerificationResult: serverVerificationResultPromise, entryStore: entryStorePromise, threadStore: threadStorePromise, userStore: userStorePromise, messageStore: messageStorePromise, updatesCurrentAsOf: initialTime, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, // We can use paths local to the on web urlPrefix: '', windowDimensions: { width: 0, height: 0 }, baseHref, connection: { ...defaultConnectionInfo('web', viewer.timeZone), actualizedCalendarQuery: calendarQuery, }, watchedThreadIDs: [], lifecycleState: 'active', nextLocalID: 0, queuedReports: [], timeZone: viewer.timeZone, userAgent: viewer.userAgent, cookie: undefined, deviceToken: undefined, dataLoaded: viewer.loggedIn, windowActive: true, }; const stateResult = await promiseAll(statePromises); const state: AppState = { ...stateResult }; const store: Store = createStore(reducer, state); const routerContext = {}; const reactStream = renderToNodeStream( , ); if (routerContext.url) { throw new ServerError('URL modified during server render!'); } reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } async function handleVerificationRequest( viewer: Viewer, code: ?string, ): Promise { if (!code) { return null; } try { return await handleCodeVerificationRequest(viewer, code); } catch (e) { if (e instanceof ServerError && e.message === 'invalid_code') { return { success: false }; } throw e; } } export { websiteResponder }; diff --git a/server/src/scripts/image-size.js b/server/src/scripts/image-size.js index a16543935..90c69adc3 100644 --- a/server/src/scripts/image-size.js +++ b/server/src/scripts/image-size.js @@ -1,36 +1,36 @@ // @flow import sizeOf from 'buffer-image-size'; import { dbQuery, SQL } from '../database/database'; import { endScript } from './utils'; async function main() { try { await addImageSizeToUploadsTable(); endScript(); } catch (e) { endScript(); console.warn(e); } } async function addImageSizeToUploadsTable() { await dbQuery(SQL`ALTER TABLE uploads ADD extra JSON NULL AFTER secret;`); const [result] = await dbQuery(SQL` SELECT id, content FROM uploads WHERE type = "photo" AND extra IS NULL `); - for (let row of result) { + for (const row of result) { const { height, width } = sizeOf(row.content); const dimensions = JSON.stringify({ height, width }); await dbQuery(SQL` UPDATE uploads SET extra = ${dimensions} WHERE id = ${row.id} `); } } main(); diff --git a/server/src/scripts/merge-users.js b/server/src/scripts/merge-users.js index abf47875e..e9cb98660 100644 --- a/server/src/scripts/merge-users.js +++ b/server/src/scripts/merge-users.js @@ -1,200 +1,200 @@ // @flow import type { Shape } from 'lib/types/core'; import type { ServerThreadInfo } from 'lib/types/thread-types'; import { type UpdateData, updateTypes } from 'lib/types/update-types'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers'; import { createScriptViewer } from '../session/scripts'; import { changeRole, commitMembershipChangeset, } from '../updaters/thread-permission-updaters'; import { endScript } from './utils'; async function main() { try { await mergeUsers('7147', '15972', { username: true, password: true }); endScript(); } catch (e) { endScript(); console.warn(e); } } type ReplaceUserInfo = Shape<{| +username: boolean, +email: boolean, +password: boolean, |}>; async function mergeUsers( fromUserID: string, toUserID: string, replaceUserInfo?: ReplaceUserInfo, ) { let updateUserRowQuery = null; let updateDatas = []; if (replaceUserInfo) { const replaceUserResult = await replaceUser( fromUserID, toUserID, replaceUserInfo, ); ({ sql: updateUserRowQuery, updateDatas } = replaceUserResult); } const usersGettingUpdate = new Set(); const usersNeedingUpdate = new Set(); const needUserInfoUpdate = replaceUserInfo && replaceUserInfo.username; const setGettingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } - for (let { id } of threadInfo.members) { + for (const { id } of threadInfo.members) { usersGettingUpdate.add(id); usersNeedingUpdate.delete(id); } }; const setNeedingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } - for (let { id } of threadInfo.members) { + for (const { id } of threadInfo.members) { if (!usersGettingUpdate.has(id)) { usersNeedingUpdate.add(id); } } }; const newThreadRolePairs = []; const { threadInfos } = await fetchServerThreadInfos(); - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const fromUserExistingMember = threadInfo.members.find( (memberInfo) => memberInfo.id === fromUserID, ); if (!fromUserExistingMember) { setNeedingUpdate(threadInfo); continue; } const { role } = fromUserExistingMember; if (!role) { // Only transfer explicit memberships setNeedingUpdate(threadInfo); continue; } const toUserExistingMember = threadInfo.members.find( (memberInfo) => memberInfo.id === toUserID, ); if (!toUserExistingMember || !toUserExistingMember.role) { setGettingUpdate(threadInfo); newThreadRolePairs.push([threadID, role]); } else { setNeedingUpdate(threadInfo); } } const fromViewer = createScriptViewer(fromUserID); await deleteAccount(fromViewer); if (updateUserRowQuery) { await dbQuery(updateUserRowQuery); } const time = Date.now(); - for (let userID of usersNeedingUpdate) { + for (const userID of usersNeedingUpdate) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID, time, updatedUserID: toUserID, }); } await createUpdates(updateDatas); const changesets = await Promise.all( newThreadRolePairs.map(([threadID, role]) => changeRole(threadID, [toUserID], role), ), ); const membershipRows = []; const relationshipRows = []; - for (let currentChangeset of changesets) { + for (const currentChangeset of changesets) { if (!currentChangeset) { throw new Error('changeRole returned null'); } const { membershipRows: currentMembershipRows, relationshipRows: currentRelationshipRows, } = currentChangeset; membershipRows.push(...currentMembershipRows); relationshipRows.push(...currentRelationshipRows); } if (membershipRows.length > 0 || relationshipRows.length > 0) { const toViewer = createScriptViewer(toUserID); const changeset = { membershipRows, relationshipRows }; await commitMembershipChangeset(toViewer, changeset); } } type ReplaceUserResult = {| sql: ?SQLStatement, updateDatas: UpdateData[], |}; async function replaceUser( fromUserID: string, toUserID: string, replaceUserInfo: ReplaceUserInfo, ): Promise { if (Object.keys(replaceUserInfo).length === 0) { return { sql: null, updateDatas: [], }; } const fromUserQuery = SQL` SELECT username, hash, email, email_verified FROM users WHERE id = ${fromUserID} `; const [fromUserResult] = await dbQuery(fromUserQuery); const [firstResult] = fromUserResult; if (!firstResult) { throw new Error(`couldn't fetch fromUserID ${fromUserID}`); } const changedFields = {}; if (replaceUserInfo.username) { changedFields.username = firstResult.username; } if (replaceUserInfo.email) { changedFields.email = firstResult.email; changedFields.email_verified = firstResult.email_verified; } if (replaceUserInfo.password) { changedFields.hash = firstResult.hash; } const updateUserRowQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${toUserID} `; const updateDatas = []; if (replaceUserInfo.username || replaceUserInfo.email) { updateDatas.push({ type: updateTypes.UPDATE_CURRENT_USER, userID: toUserID, time: Date.now(), }); } return { sql: updateUserRowQuery, updateDatas, }; } main(); diff --git a/server/src/search/users.js b/server/src/search/users.js index 02bae8f24..a8cff2f68 100644 --- a/server/src/search/users.js +++ b/server/src/search/users.js @@ -1,30 +1,30 @@ // @flow import type { UserSearchRequest } from 'lib/types/search-types'; import type { GlobalAccountUserInfo } from 'lib/types/user-types'; import { dbQuery, SQL } from '../database/database'; async function searchForUsers( query: UserSearchRequest, ): Promise { const sqlQuery = SQL`SELECT id, username FROM users `; const prefix = query.prefix; if (prefix) { sqlQuery.append(SQL`WHERE LOWER(username) LIKE LOWER(${prefix + '%'}) `); } sqlQuery.append(SQL`LIMIT 20`); const [result] = await dbQuery(sqlQuery); const userInfos = []; - for (let row of result) { + for (const row of result) { userInfos.push({ id: row.id.toString(), username: row.username, }); } return userInfos; } export { searchForUsers }; diff --git a/server/src/session/bots.js b/server/src/session/bots.js index d933e07a9..5c7a02123 100644 --- a/server/src/session/bots.js +++ b/server/src/session/bots.js @@ -1,37 +1,37 @@ // @flow import bots from 'lib/facts/bots'; import { ServerError } from 'lib/utils/errors'; import { Viewer } from './viewer'; // Note that since the returned Viewer doesn't have a valid cookieID or // sessionID, a lot of things can go wrong when trying to use it with certain // functions. function createBotViewer(userID: string): Viewer { let userIDIsBot = false; - for (let botName in bots) { + for (const botName in bots) { if (bots[botName].userID === userID) { userIDIsBot = true; break; } } if (!userIDIsBot) { throw new ServerError('invalid_bot_id'); } return new Viewer({ isSocket: true, loggedIn: true, id: userID, platformDetails: null, deviceToken: null, userID, cookieID: null, cookiePassword: null, sessionID: null, sessionInfo: null, isScriptViewer: true, }); } export { createBotViewer }; diff --git a/server/src/socket/session-utils.js b/server/src/socket/session-utils.js index e54f0f3cd..0f2b6ed76 100644 --- a/server/src/socket/session-utils.js +++ b/server/src/socket/session-utils.js @@ -1,511 +1,511 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import { usersInRawEntryInfos, serverEntryInfo, serverEntryInfosObject, } from 'lib/shared/entry-utils'; import { usersInThreadInfo } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { UpdateActivityResult } from 'lib/types/activity-types'; import { isDeviceType } from 'lib/types/device-types'; import type { CalendarQuery, DeltaEntryInfosResponse, } from 'lib/types/entry-types'; import { reportTypes, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, } from 'lib/types/report-types'; import { serverRequestTypes, type ThreadInconsistencyClientResponse, type EntryInconsistencyClientResponse, type ClientResponse, type ServerRequest, type CheckStateServerRequest, } from 'lib/types/request-types'; import { sessionCheckFrequency } from 'lib/types/session-types'; import { hash } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import createReport from '../creators/report-creator'; import { SQL } from '../database/database'; import { fetchEntryInfos, fetchEntryInfosByID, fetchEntriesForSession, } from '../fetchers/entry-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchCurrentUserInfo, fetchUserInfos, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { activityUpdatesInputValidator } from '../responders/activity-responders'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from '../responders/report-responders'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { activityUpdater } from '../updaters/activity-updaters'; import { compareNewCalendarQuery } from '../updaters/entry-updaters'; import type { SessionUpdate } from '../updaters/session-updaters'; import { tShape, tPlatform, tPlatformDetails } from '../utils/validation-utils'; const clientResponseInputValidator = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', (x) => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.THREAD_INCONSISTENCY', (x) => x === serverRequestTypes.THREAD_INCONSISTENCY, ), }), tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.ENTRY_INCONSISTENCY', (x) => x === serverRequestTypes.ENTRY_INCONSISTENCY, ), }), tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM_DETAILS', (x) => x === serverRequestTypes.PLATFORM_DETAILS, ), platformDetails: tPlatformDetails, }), tShape({ type: t.irreducible( 'serverRequestTypes.CHECK_STATE', (x) => x === serverRequestTypes.CHECK_STATE, ), hashResults: t.dict(t.String, t.Boolean), }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATES', (x) => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATES, ), activityUpdates: activityUpdatesInputValidator, }), ]); type StateCheckStatus = | {| status: 'state_validated' |} | {| status: 'state_invalid', invalidKeys: $ReadOnlyArray |} | {| status: 'state_check' |}; type ProcessClientResponsesResult = {| serverRequests: ServerRequest[], stateCheckStatus: ?StateCheckStatus, activityUpdateResult: ?UpdateActivityResult, |}; async function processClientResponses( viewer: Viewer, clientResponses: $ReadOnlyArray, ): Promise { let viewerMissingPlatform = !viewer.platform; const { platformDetails } = viewer; let viewerMissingPlatformDetails = !platformDetails || (isDeviceType(viewer.platform) && (platformDetails.codeVersion === null || platformDetails.codeVersion === undefined || platformDetails.stateVersion === null || platformDetails.stateVersion === undefined)); const promises = []; let activityUpdates = []; let stateCheckStatus = null; const clientSentPlatformDetails = clientResponses.some( (response) => response.type === serverRequestTypes.PLATFORM_DETAILS, ); - for (let clientResponse of clientResponses) { + for (const clientResponse of clientResponses) { if ( clientResponse.type === serverRequestTypes.PLATFORM && !clientSentPlatformDetails ) { promises.push(setCookiePlatform(viewer, clientResponse.platform)); viewerMissingPlatform = false; if (!isDeviceType(clientResponse.platform)) { viewerMissingPlatformDetails = false; } } else if ( clientResponse.type === serverRequestTypes.THREAD_INCONSISTENCY ) { promises.push(recordThreadInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.ENTRY_INCONSISTENCY) { promises.push(recordEntryInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.PLATFORM_DETAILS) { promises.push( setCookiePlatformDetails(viewer, clientResponse.platformDetails), ); viewerMissingPlatform = false; viewerMissingPlatformDetails = false; } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATES ) { activityUpdates = [...activityUpdates, ...clientResponse.activityUpdates]; } else if (clientResponse.type === serverRequestTypes.CHECK_STATE) { const invalidKeys = []; - for (let key in clientResponse.hashResults) { + for (const key in clientResponse.hashResults) { const result = clientResponse.hashResults[key]; if (!result) { invalidKeys.push(key); } } stateCheckStatus = invalidKeys.length > 0 ? { status: 'state_invalid', invalidKeys } : { status: 'state_validated' }; } } let activityUpdateResult; if (activityUpdates.length > 0 || promises.length > 0) { [activityUpdateResult] = await Promise.all([ activityUpdates.length > 0 ? activityUpdater(viewer, { updates: activityUpdates }) : undefined, promises.length > 0 ? Promise.all(promises) : undefined, ]); } if ( !stateCheckStatus && viewer.loggedIn && viewer.sessionLastValidated + sessionCheckFrequency < Date.now() ) { stateCheckStatus = { status: 'state_check' }; } const serverRequests = []; if (viewerMissingPlatform) { serverRequests.push({ type: serverRequestTypes.PLATFORM }); } if (viewerMissingPlatformDetails) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } return { serverRequests, stateCheckStatus, activityUpdateResult }; } async function recordThreadInconsistency( viewer: Viewer, response: ThreadInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.THREAD_INCONSISTENCY, }: ThreadInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } async function recordEntryInconsistency( viewer: Viewer, response: EntryInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.ENTRY_INCONSISTENCY, }: EntryInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } type SessionInitializationResult = | {| sessionContinued: false |} | {| sessionContinued: true, deltaEntryInfoResult: DeltaEntryInfosResponse, sessionUpdate: SessionUpdate, |}; async function initializeSession( viewer: Viewer, calendarQuery: CalendarQuery, oldLastUpdate: number, ): Promise { if (!viewer.loggedIn) { return { sessionContinued: false }; } let comparisonResult = null; try { comparisonResult = compareNewCalendarQuery(viewer, calendarQuery); } catch (e) { if (e.message !== 'unknown_error') { throw e; } } if (comparisonResult) { const { difference, oldCalendarQuery } = comparisonResult; const sessionUpdate = { ...comparisonResult.sessionUpdate, lastUpdate: oldLastUpdate, }; const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } } type StateCheckResult = {| sessionUpdate?: SessionUpdate, checkStateRequest?: CheckStateServerRequest, |}; async function checkState( viewer: Viewer, status: StateCheckStatus, calendarQuery: CalendarQuery, ): Promise { const shouldCheckUserInfos = hasMinCodeVersion(viewer.platformDetails, 59); if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { const promises = { threadsResult: fetchThreadInfos(viewer), entriesResult: fetchEntryInfos(viewer, [calendarQuery]), currentUserInfo: fetchCurrentUserInfo(viewer), userInfosResult: undefined, }; if (shouldCheckUserInfos) { promises.userInfosResult = fetchKnownUserInfos(viewer); } const fetchedData = await promiseAll(promises); let hashesToCheck = { threadInfos: hash(fetchedData.threadsResult.threadInfos), entryInfos: hash( serverEntryInfosObject(fetchedData.entriesResult.rawEntryInfos), ), currentUserInfo: hash(fetchedData.currentUserInfo), }; if (shouldCheckUserInfos) { hashesToCheck = { ...hashesToCheck, userInfos: hash(fetchedData.userInfosResult), }; } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, }; return { checkStateRequest }; } const { invalidKeys } = status; let fetchAllThreads = false, fetchAllEntries = false, fetchAllUserInfos = false, fetchUserInfo = false; const threadIDsToFetch = [], entryIDsToFetch = [], userIDsToFetch = []; - for (let key of invalidKeys) { + for (const key of invalidKeys) { if (key === 'threadInfos') { fetchAllThreads = true; } else if (key === 'entryInfos') { fetchAllEntries = true; } else if (key === 'userInfos') { fetchAllUserInfos = true; } else if (key === 'currentUserInfo') { fetchUserInfo = true; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); threadIDsToFetch.push(threadID); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); entryIDsToFetch.push(entryID); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); userIDsToFetch.push(userID); } } const fetchPromises = {}; if (fetchAllThreads) { fetchPromises.threadsResult = fetchThreadInfos(viewer); } else if (threadIDsToFetch.length > 0) { fetchPromises.threadsResult = fetchThreadInfos( viewer, SQL`t.id IN (${threadIDsToFetch})`, ); } if (fetchAllEntries) { fetchPromises.entriesResult = fetchEntryInfos(viewer, [calendarQuery]); } else if (entryIDsToFetch.length > 0) { fetchPromises.entryInfos = fetchEntryInfosByID(viewer, entryIDsToFetch); } if (fetchAllUserInfos) { fetchPromises.userInfos = fetchKnownUserInfos(viewer); } else if (userIDsToFetch.length > 0) { fetchPromises.userInfos = fetchKnownUserInfos(viewer, userIDsToFetch); } if (fetchUserInfo) { fetchPromises.currentUserInfo = fetchCurrentUserInfo(viewer); } const fetchedData = await promiseAll(fetchPromises); const hashesToCheck = {}, failUnmentioned = {}, stateChanges = {}; - for (let key of invalidKeys) { + for (const key of invalidKeys) { if (key === 'threadInfos') { // Instead of returning all threadInfos, we want to narrow down and figure // out which threadInfos don't match first const { threadInfos } = fetchedData.threadsResult; - for (let threadID in threadInfos) { + for (const threadID in threadInfos) { hashesToCheck[`threadInfo|${threadID}`] = hash(threadInfos[threadID]); } failUnmentioned.threadInfos = true; } else if (key === 'entryInfos') { // Instead of returning all entryInfos, we want to narrow down and figure // out which entryInfos don't match first const { rawEntryInfos } = fetchedData.entriesResult; - for (let rawEntryInfo of rawEntryInfos) { + for (const rawEntryInfo of rawEntryInfos) { const entryInfo = serverEntryInfo(rawEntryInfo); invariant(entryInfo, 'should be set'); const { id: entryID } = entryInfo; invariant(entryID, 'should be set'); hashesToCheck[`entryInfo|${entryID}`] = hash(entryInfo); } failUnmentioned.entryInfos = true; } else if (key === 'userInfos') { // Instead of returning all userInfos, we want to narrow down and figure // out which userInfos don't match first const { userInfos } = fetchedData; - for (let userID in userInfos) { + for (const userID in userInfos) { hashesToCheck[`userInfo|${userID}`] = hash(userInfos[userID]); } failUnmentioned.userInfos = true; } else if (key === 'currentUserInfo') { stateChanges.currentUserInfo = fetchedData.currentUserInfo; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); const { threadInfos } = fetchedData.threadsResult; const threadInfo = threadInfos[threadID]; if (!threadInfo) { if (!stateChanges.deleteThreadIDs) { stateChanges.deleteThreadIDs = []; } stateChanges.deleteThreadIDs.push(threadID); continue; } if (!stateChanges.rawThreadInfos) { stateChanges.rawThreadInfos = []; } stateChanges.rawThreadInfos.push(threadInfo); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); const rawEntryInfos = fetchedData.entriesResult ? fetchedData.entriesResult.rawEntryInfos : fetchedData.entryInfos; const entryInfo = rawEntryInfos.find( (candidate) => candidate.id === entryID, ); if (!entryInfo) { if (!stateChanges.deleteEntryIDs) { stateChanges.deleteEntryIDs = []; } stateChanges.deleteEntryIDs.push(entryID); continue; } if (!stateChanges.rawEntryInfos) { stateChanges.rawEntryInfos = []; } stateChanges.rawEntryInfos.push(entryInfo); } else if (key.startsWith('userInfo|')) { const { userInfos: fetchedUserInfos } = fetchedData; const [, userID] = key.split('|'); const userInfo = fetchedUserInfos[userID]; if (!userInfo || !userInfo.username) { if (!stateChanges.deleteUserInfoIDs) { stateChanges.deleteUserInfoIDs = []; } stateChanges.deleteUserInfoIDs.push(userID); } else { if (!stateChanges.userInfos) { stateChanges.userInfos = []; } stateChanges.userInfos.push({ ...userInfo, // Flow gets confused if we don't do this username: userInfo.username, }); } } } if (!shouldCheckUserInfos) { const userIDs = new Set(); if (stateChanges.rawThreadInfos) { - for (let threadInfo of stateChanges.rawThreadInfos) { - for (let userID of usersInThreadInfo(threadInfo)) { + for (const threadInfo of stateChanges.rawThreadInfos) { + for (const userID of usersInThreadInfo(threadInfo)) { userIDs.add(userID); } } } if (stateChanges.rawEntryInfos) { - for (let userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { + for (const userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { userIDs.add(userID); } } const userInfos = []; if (userIDs.size > 0) { const fetchedUserInfos = await fetchUserInfos([...userIDs]); - for (let userID in fetchedUserInfos) { + for (const userID in fetchedUserInfos) { const userInfo = fetchedUserInfos[userID]; if (userInfo && userInfo.username) { const { id, username } = userInfo; userInfos.push({ id, username }); } } } if (userInfos.length > 0) { stateChanges.userInfos = userInfos; } } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, failUnmentioned, stateChanges, }; if (Object.keys(hashesToCheck).length === 0) { return { checkStateRequest, sessionUpdate: { lastValidated: Date.now() } }; } else { return { checkStateRequest }; } } export { clientResponseInputValidator, processClientResponses, initializeSession, checkState, }; diff --git a/server/src/socket/socket.js b/server/src/socket/socket.js index 3ab23196d..afda369d3 100644 --- a/server/src/socket/socket.js +++ b/server/src/socket/socket.js @@ -1,814 +1,814 @@ // @flow import type { $Request } from 'express'; import invariant from 'invariant'; import _debounce from 'lodash/debounce'; import t from 'tcomb'; import type { WebSocket } from 'ws'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { serverRequestSocketTimeout, serverResponseTimeout, } from 'lib/shared/timeouts'; import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils'; import type { Shape } from 'lib/types/core'; import { endpointIsSocketSafe } from 'lib/types/endpoints'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types'; import { cookieSources, sessionCheckFrequency, stateCheckInactivityActivationInterval, } from 'lib/types/session-types'; import { type ClientSocketMessage, type InitialClientSocketMessage, type ResponsesClientSocketMessage, type StateSyncFullSocketPayload, type ServerSocketMessage, type ErrorServerSocketMessage, type AuthErrorServerSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, clientSocketMessageTypes, stateSyncPayloadTypes, serverSocketMessageTypes, } from 'lib/types/socket-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import SequentialPromiseResolver from 'lib/utils/sequential-promise-resolver'; import sleep from 'lib/utils/sleep'; import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { deleteUpdatesBeforeTimeTargetingSession } from '../deleters/update-deleters'; import { jsonEndpoints } from '../endpoints'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchUpdateInfos } from '../fetchers/update-fetchers'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { newEntryQueryInputValidator, verifyCalendarQueryThreadIDs, } from '../responders/entry-responders'; import { handleAsyncPromise } from '../responders/handlers'; import { fetchViewerForSocket, extendCookieLifespan, createNewAnonymousCookie, } from '../session/cookies'; import { Viewer } from '../session/viewer'; import { updateActivityTime } from '../updaters/activity-updaters'; import { commitSessionUpdate } from '../updaters/session-updaters'; import { assertSecureRequest } from '../utils/security-utils'; import { checkInputValidator, checkClientSupported, tShape, tCookie, } from '../utils/validation-utils'; import { RedisSubscriber } from './redis'; import { clientResponseInputValidator, processClientResponses, initializeSession, checkState, } from './session-utils'; const clientSocketMessageInputValidator = t.union([ tShape({ type: t.irreducible( 'clientSocketMessageTypes.INITIAL', (x) => x === clientSocketMessageTypes.INITIAL, ), id: t.Number, payload: tShape({ sessionIdentification: tShape({ cookie: t.maybe(tCookie), sessionID: t.maybe(t.String), }), sessionState: tShape({ calendarQuery: newEntryQueryInputValidator, messagesCurrentAsOf: t.Number, updatesCurrentAsOf: t.Number, watchedIDs: t.list(t.String), }), clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.RESPONSES', (x) => x === clientSocketMessageTypes.RESPONSES, ), id: t.Number, payload: tShape({ clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.PING', (x) => x === clientSocketMessageTypes.PING, ), id: t.Number, }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.ACK_UPDATES', (x) => x === clientSocketMessageTypes.ACK_UPDATES, ), id: t.Number, payload: tShape({ currentAsOf: t.Number, }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.API_REQUEST', (x) => x === clientSocketMessageTypes.API_REQUEST, ), id: t.Number, payload: tShape({ endpoint: t.String, input: t.Object, }), }), ]); function onConnection(ws: WebSocket, req: $Request) { assertSecureRequest(req); new Socket(ws, req); } type StateCheckConditions = {| activityRecentlyOccurred: boolean, stateCheckOngoing: boolean, |}; class Socket { ws: WebSocket; httpRequest: $Request; viewer: ?Viewer; redis: ?RedisSubscriber; redisPromiseResolver: SequentialPromiseResolver; stateCheckConditions: StateCheckConditions = { activityRecentlyOccurred: true, stateCheckOngoing: false, }; stateCheckTimeoutID: ?TimeoutID; constructor(ws: WebSocket, httpRequest: $Request) { this.ws = ws; this.httpRequest = httpRequest; ws.on('message', this.onMessage); ws.on('close', this.onClose); this.resetTimeout(); this.redisPromiseResolver = new SequentialPromiseResolver(this.sendMessage); } onMessage = async (messageString: string) => { let clientSocketMessage: ?ClientSocketMessage; try { this.resetTimeout(); const message = JSON.parse(messageString); checkInputValidator(clientSocketMessageInputValidator, message); clientSocketMessage = message; if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { if (this.viewer) { // This indicates that the user sent multiple INITIAL messages. throw new ServerError('socket_already_initialized'); } this.viewer = await fetchViewerForSocket( this.httpRequest, clientSocketMessage, ); if (!this.viewer) { // This indicates that the cookie was invalid, but the client is using // cookieSources.HEADER and thus can't accept a new cookie over // WebSockets. See comment under catch block for socket_deauthorized. throw new ServerError('socket_deauthorized'); } } const { viewer } = this; if (!viewer) { // This indicates a non-INITIAL message was sent by the client before // the INITIAL message. throw new ServerError('socket_uninitialized'); } if (viewer.sessionChanged) { // This indicates that the cookie was invalid, and we've assigned a new // anonymous one. throw new ServerError('socket_deauthorized'); } if (!viewer.loggedIn) { // This indicates that the specified cookie was an anonymous one. throw new ServerError('not_logged_in'); } await checkClientSupported( viewer, clientSocketMessageInputValidator, clientSocketMessage, ); const serverResponses = await this.handleClientSocketMessage( clientSocketMessage, ); if (!this.redis) { this.redis = new RedisSubscriber( { userID: viewer.userID, sessionID: viewer.session }, this.onRedisMessage, ); } if (viewer.sessionChanged) { // This indicates that something has caused the session to change, which // shouldn't happen from inside a WebSocket since we can't handle cookie // invalidation. throw new ServerError('session_mutated_from_socket'); } handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); - for (let response of serverResponses) { + for (const response of serverResponses) { this.sendMessage(response); } if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { this.onSuccessfulConnection(); } } catch (error) { console.warn(error); if (!(error instanceof ServerError)) { const errorMessage: ErrorServerSocketMessage = { type: serverSocketMessageTypes.ERROR, message: error.message, }; const responseTo = clientSocketMessage ? clientSocketMessage.id : null; if (responseTo !== null) { errorMessage.responseTo = responseTo; } this.markActivityOccurred(); this.sendMessage(errorMessage); return; } invariant(clientSocketMessage, 'should be set'); const responseTo = clientSocketMessage.id; if (error.message === 'socket_deauthorized') { const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (this.viewer) { // viewer should only be falsey for cookieSources.HEADER (web) // clients. Usually if the cookie is invalid we construct a new // anonymous Viewer with a new cookie, and then pass the cookie down // in the error. But we can't pass HTTP cookies in WebSocket messages. authErrorMessage.sessionChange = { cookie: this.viewer.cookiePairString, currentUserInfo: { id: this.viewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4100, error.message); return; } else if (error.message === 'client_version_unsupported') { const { viewer } = this; invariant(viewer, 'should be set'); const promises = {}; promises.deleteCookie = deleteCookie(viewer.cookieID); if (viewer.cookieSource !== cookieSources.BODY) { promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails: error.platformDetails, deviceToken: viewer.deviceToken, }); } const { anonymousViewerData } = await promiseAll(promises); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (anonymousViewerData) { // It is normally not safe to pass the result of // createNewAnonymousCookie to the Viewer constructor. That is because // createNewAnonymousCookie leaves several fields of // AnonymousViewerData unset, and consequently Viewer will throw when // access is attempted. It is only safe here because we can guarantee // that only cookiePairString and cookieID are accessed on anonViewer // below. const anonViewer = new Viewer(anonymousViewerData); authErrorMessage.sessionChange = { cookie: anonViewer.cookiePairString, currentUserInfo: { id: anonViewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4101, error.message); return; } if (error.payload) { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, payload: error.payload, }); } else { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, }); } if (error.message === 'not_logged_in') { this.ws.close(4102, error.message); } else if (error.message === 'session_mutated_from_socket') { this.ws.close(4103, error.message); } else { this.markActivityOccurred(); } } }; onClose = async () => { this.clearStateCheckTimeout(); this.resetTimeout.cancel(); this.debouncedAfterActivity.cancel(); if (this.viewer && this.viewer.hasSessionInfo) { await deleteActivityForViewerSession(this.viewer); } if (this.redis) { this.redis.quit(); this.redis = null; } }; sendMessage = (message: ServerSocketMessage) => { invariant( this.ws.readyState > 0, "shouldn't send message until connection established", ); if (this.ws.readyState === 1) { this.ws.send(JSON.stringify(message)); } }; async handleClientSocketMessage( message: ClientSocketMessage, ): Promise { const resultPromise = (async () => { if (message.type === clientSocketMessageTypes.INITIAL) { this.markActivityOccurred(); return await this.handleInitialClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.markActivityOccurred(); return await this.handleResponsesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.PING) { return await this.handlePingClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.markActivityOccurred(); return await this.handleAckUpdatesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.markActivityOccurred(); return await this.handleAPIRequestClientSocketMessage(message); } return []; })(); const timeoutPromise = (async () => { await sleep(serverResponseTimeout); throw new ServerError('socket_response_timeout'); })(); return await Promise.race([resultPromise, timeoutPromise]); } async handleInitialClientSocketMessage( message: InitialClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const responses = []; const { sessionState, clientResponses } = message.payload; const { calendarQuery, updatesCurrentAsOf: oldUpdatesCurrentAsOf, messagesCurrentAsOf: oldMessagesCurrentAsOf, watchedIDs, } = sessionState; await verifyCalendarQueryThreadIDs(calendarQuery); const sessionInitializationResult = await initializeSession( viewer, calendarQuery, oldUpdatesCurrentAsOf, ); const threadCursors = {}; - for (let watchedThreadID of watchedIDs) { + for (const watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ fetchMessagesResult, { serverRequests, activityUpdateResult }, ] = await Promise.all([ fetchMessageInfosSince( viewer, threadSelectionCriteria, oldMessagesCurrentAsOf, defaultNumberPerThread, ), processClientResponses(viewer, clientResponses), ]); const messagesResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( fetchMessagesResult.rawMessageInfos, oldMessagesCurrentAsOf, ), }; if (!sessionInitializationResult.sessionContinued) { const [ threadsResult, entriesResult, currentUserInfo, knownUserInfos, ] = await Promise.all([ fetchThreadInfos(viewer), fetchEntryInfos(viewer, [calendarQuery]), fetchCurrentUserInfo(viewer), fetchKnownUserInfos(viewer), ]); const payload: StateSyncFullSocketPayload = { type: stateSyncPayloadTypes.FULL, messagesResult, threadInfos: threadsResult.threadInfos, currentUserInfo, rawEntryInfos: entriesResult.rawEntryInfos, userInfos: values(knownUserInfos), updatesCurrentAsOf: oldUpdatesCurrentAsOf, }; if (viewer.sessionChanged) { // If initializeSession encounters sessionIdentifierTypes.BODY_SESSION_ID, // but the session is unspecified or expired, it will set a new sessionID // and specify viewer.sessionChanged const { sessionID } = viewer; invariant( sessionID !== null && sessionID !== undefined, 'should be set', ); payload.sessionID = sessionID; viewer.sessionChanged = false; } responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload, }); } else { const { sessionUpdate, deltaEntryInfoResult, } = sessionInitializationResult; const promises = {}; promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargetingSession( viewer, oldUpdatesCurrentAsOf, ); promises.fetchUpdateResult = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); promises.sessionUpdate = commitSessionUpdate(viewer, sessionUpdate); const { fetchUpdateResult } = await promiseAll(promises); const { updateInfos, userInfos } = fetchUpdateResult; const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp( [...updateInfos], oldUpdatesCurrentAsOf, ); const updatesResult = { newUpdates: updateInfos, currentAsOf: newUpdatesCurrentAsOf, }; responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload: { type: stateSyncPayloadTypes.INCREMENTAL, messagesResult, updatesResult, deltaEntryInfos: deltaEntryInfoResult.rawEntryInfos, deletedEntryIDs: deltaEntryInfoResult.deletedEntryIDs, userInfos: values(userInfos), }, }); } if (serverRequests.length > 0 || clientResponses.length > 0) { // We send this message first since the STATE_SYNC triggers the client's // connection status to shift to "connected", and we want to make sure the // client responses are cleared from Redux before that happens responses.unshift({ type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }); } if (activityUpdateResult) { // Same reason for unshifting as above responses.unshift({ type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: message.id, payload: activityUpdateResult, }); } return responses; } async handleResponsesClientSocketMessage( message: ResponsesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { clientResponses } = message.payload; const { stateCheckStatus } = await processClientResponses( viewer, clientResponses, ); const serverRequests = []; if (stateCheckStatus && stateCheckStatus.status !== 'state_check') { const { sessionUpdate, checkStateRequest } = await checkState( viewer, stateCheckStatus, viewer.calendarQuery, ); if (sessionUpdate) { await commitSessionUpdate(viewer, sessionUpdate); this.setStateCheckConditions({ stateCheckOngoing: false }); } if (checkStateRequest) { serverRequests.push(checkStateRequest); } } // We send a response message regardless of whether we have any requests, // since we need to ack the client's responses return [ { type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }, ]; } async handlePingClientSocketMessage( message: PingClientSocketMessage, ): Promise { this.updateActivityTime(); return [ { type: serverSocketMessageTypes.PONG, responseTo: message.id, }, ]; } async handleAckUpdatesClientSocketMessage( message: AckUpdatesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { currentAsOf } = message.payload; await Promise.all([ deleteUpdatesBeforeTimeTargetingSession(viewer, currentAsOf), commitSessionUpdate(viewer, { lastUpdate: currentAsOf }), ]); return []; } async handleAPIRequestClientSocketMessage( message: APIRequestClientSocketMessage, ): Promise { if (!endpointIsSocketSafe(message.payload.endpoint)) { throw new ServerError('endpoint_unsafe_for_socket'); } const { viewer } = this; invariant(viewer, 'should be set'); const responder = jsonEndpoints[message.payload.endpoint]; const response = await responder(viewer, message.payload.input); return [ { type: serverSocketMessageTypes.API_RESPONSE, responseTo: message.id, payload: response, }, ]; } onRedisMessage = async (message: RedisMessage) => { try { await this.processRedisMessage(message); } catch (e) { console.warn(e); } }; async processRedisMessage(message: RedisMessage) { if (message.type === redisMessageTypes.START_SUBSCRIPTION) { this.ws.terminate(); } else if (message.type === redisMessageTypes.NEW_UPDATES) { const { viewer } = this; invariant(viewer, 'should be set'); if (message.ignoreSession && message.ignoreSession === viewer.session) { return; } const rawUpdateInfos = message.updates; this.redisPromiseResolver.add( (async () => { const { updateInfos, userInfos, } = await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { viewer, }); if (updateInfos.length === 0) { console.warn( 'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES', ); return null; } this.markActivityOccurred(); return { type: serverSocketMessageTypes.UPDATES, payload: { updatesResult: { currentAsOf: mostRecentUpdateTimestamp([...updateInfos], 0), newUpdates: updateInfos, }, userInfos: values(userInfos), }, }; })(), ); } else if (message.type === redisMessageTypes.NEW_MESSAGES) { const { viewer } = this; invariant(viewer, 'should be set'); const rawMessageInfos = message.messages; const messageFetchResult = getMessageFetchResultFromRedisMessages( viewer, rawMessageInfos, ); if (messageFetchResult.rawMessageInfos.length === 0) { console.warn( 'could not get any rawMessageInfos from ' + 'redisMessageTypes.NEW_MESSAGES', ); return; } this.redisPromiseResolver.add( (async () => { this.markActivityOccurred(); return { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messageFetchResult.rawMessageInfos, truncationStatuses: messageFetchResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( messageFetchResult.rawMessageInfos, 0, ), }, }, }; })(), ); } } onSuccessfulConnection() { if (this.ws.readyState !== 1) { return; } this.handleStateCheckConditionsUpdate(); } updateActivityTime() { const { viewer } = this; invariant(viewer, 'should be set'); handleAsyncPromise(updateActivityTime(viewer)); } // The Socket will timeout by calling this.ws.terminate() // serverRequestSocketTimeout milliseconds after the last // time resetTimeout is called resetTimeout = _debounce( () => this.ws.terminate(), serverRequestSocketTimeout, ); debouncedAfterActivity = _debounce( () => this.setStateCheckConditions({ activityRecentlyOccurred: false }), stateCheckInactivityActivationInterval, ); markActivityOccurred = () => { if (this.ws.readyState !== 1) { return; } this.setStateCheckConditions({ activityRecentlyOccurred: true }); this.debouncedAfterActivity(); }; clearStateCheckTimeout() { const { stateCheckTimeoutID } = this; if (stateCheckTimeoutID) { clearTimeout(stateCheckTimeoutID); this.stateCheckTimeoutID = null; } } setStateCheckConditions(newConditions: Shape) { this.stateCheckConditions = { ...this.stateCheckConditions, ...newConditions, }; this.handleStateCheckConditionsUpdate(); } get stateCheckCanStart() { return Object.values(this.stateCheckConditions).every((cond) => !cond); } handleStateCheckConditionsUpdate() { if (!this.stateCheckCanStart) { this.clearStateCheckTimeout(); return; } if (this.stateCheckTimeoutID) { return; } const { viewer } = this; if (!viewer) { return; } const timeUntilStateCheck = viewer.sessionLastValidated + sessionCheckFrequency - Date.now(); if (timeUntilStateCheck <= 0) { this.initiateStateCheck(); } else { this.stateCheckTimeoutID = setTimeout( this.initiateStateCheck, timeUntilStateCheck, ); } } initiateStateCheck = async () => { this.setStateCheckConditions({ stateCheckOngoing: true }); const { viewer } = this; invariant(viewer, 'should be set'); const { checkStateRequest } = await checkState( viewer, { status: 'state_check' }, viewer.calendarQuery, ); invariant(checkStateRequest, 'should be set'); this.sendMessage({ type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [checkStateRequest] }, }); }; } export { onConnection }; diff --git a/server/src/updaters/account-updaters.js b/server/src/updaters/account-updaters.js index eb995ab58..fe52bb646 100644 --- a/server/src/updaters/account-updaters.js +++ b/server/src/updaters/account-updaters.js @@ -1,258 +1,258 @@ // @flow import bcrypt from 'twin-bcrypt'; import { validEmailRegex } from 'lib/shared/account-utils'; import type { ResetPasswordRequest, LogInResponse, UpdatePasswordRequest, } from 'lib/types/account-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { updateTypes } from 'lib/types/update-types'; import type { AccountUpdate } from 'lib/types/user-types'; import { verifyField } from 'lib/types/verify-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { sendPasswordResetEmail } from '../emails/reset-password'; import { sendEmailAddressVerificationEmail } from '../emails/verification'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import { verifyCode, clearVerifyCodes } from '../models/verification'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders'; import { createNewUserCookie, setNewSession } from '../session/cookies'; import type { Viewer } from '../session/viewer'; async function accountUpdater( viewer: Viewer, update: AccountUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const email = update.updatedFields.email; const newPassword = update.updatedFields.password; const fetchPromises = {}; if (email) { if (email.search(validEmailRegex) === -1) { throw new ServerError('invalid_email'); } fetchPromises.emailQuery = dbQuery(SQL` SELECT COUNT(id) AS count FROM users WHERE email = ${email} `); } fetchPromises.verifyQuery = dbQuery(SQL` SELECT username, email, hash FROM users WHERE id = ${viewer.userID} `); const { verifyQuery, emailQuery } = await promiseAll(fetchPromises); const [verifyResult] = verifyQuery; if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const savePromises = []; const changedFields = {}; let currentUserInfoChanged = false; if (email && email !== verifyRow.email) { const [emailResult] = emailQuery; const emailRow = emailResult[0]; if (emailRow.count !== 0) { throw new ServerError('email_taken'); } changedFields.email = email; changedFields.email_verified = 0; currentUserInfoChanged = true; savePromises.push( sendEmailAddressVerificationEmail( viewer.userID, verifyRow.username, email, ), ); } if (newPassword) { changedFields.hash = bcrypt.hashSync(newPassword); } if (Object.keys(changedFields).length > 0) { savePromises.push( dbQuery(SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `), ); } await Promise.all(savePromises); if (currentUserInfoChanged) { const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } } async function checkAndSendVerificationEmail(viewer: Viewer): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const query = SQL` SELECT username, email, email_verified FROM users WHERE id = ${viewer.userID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; if (row.email_verified) { throw new ServerError('already_verified'); } await sendEmailAddressVerificationEmail( viewer.userID, row.username, row.email, ); } async function checkAndSendPasswordResetEmail(request: ResetPasswordRequest) { const query = SQL` SELECT id, username, email FROM users WHERE LCASE(username) = LCASE(${request.usernameOrEmail}) OR LCASE(email) = LCASE(${request.usernameOrEmail}) `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_user'); } const row = result[0]; await sendPasswordResetEmail(row.id.toString(), row.username, row.email); } async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } const calendarQuery = request.calendarQuery; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } promises.verificationResult = verifyCode(request.code); const { verificationResult } = await promiseAll(promises); const { userID, field } = verificationResult; if (field !== verifyField.RESET_PASSWORD) { throw new ServerError('invalid_code'); } const userQuery = SQL` SELECT username, email, email_verified FROM users WHERE id = ${userID} `; const hash = bcrypt.hashSync(request.password); const updateQuery = SQL`UPDATE users SET hash = ${hash} WHERE id = ${userID}`; const [[userResult]] = await Promise.all([ dbQuery(userQuery), dbQuery(updateQuery), ]); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, }), clearVerifyCodes(verificationResult), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; - for (let watchedThreadID of request.watchedIDs) { + for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo: { id: userID, username: userRow.username, email: userRow.email, emailVerified: !!userRow.email_verified, }, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } export { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, }; diff --git a/server/src/updaters/relationship-updaters.js b/server/src/updaters/relationship-updaters.js index aa8fe2ee8..2433bc4a8 100644 --- a/server/src/updaters/relationship-updaters.js +++ b/server/src/updaters/relationship-updaters.js @@ -1,315 +1,315 @@ // @flow import invariant from 'invariant'; import { sortIDs } from 'lib/shared/relationship-utils'; import { messageTypes } from 'lib/types/message-types'; import { type RelationshipRequest, type RelationshipErrors, type UndirectedRelationshipRow, relationshipActions, undirectedStatus, directedStatus, } from 'lib/types/relationship-types'; import { threadTypes } from 'lib/types/thread-types'; import { updateTypes, type UpdateData } from 'lib/types/update-types'; import { cartesianProduct } from 'lib/utils/array'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import createMessages from '../creators/message-creator'; import { createThread } from '../creators/thread-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchFriendRequestRelationshipOperations } from '../fetchers/relationship-fetchers'; import { fetchUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; async function updateRelationships( viewer: Viewer, request: RelationshipRequest, ): Promise { const { action } = request; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const uniqueUserIDs = [...new Set(request.userIDs)]; const users = await fetchUserInfos(uniqueUserIDs); let errors = {}; const userIDs: string[] = []; - for (let userID of uniqueUserIDs) { + for (const userID of uniqueUserIDs) { if (userID === viewer.userID || !users[userID].username) { const acc = errors.invalid_user || []; errors.invalid_user = [...acc, userID]; } else { userIDs.push(userID); } } if (!userIDs.length) { return Object.freeze({ ...errors }); } const updateIDs = []; if (action === relationshipActions.FRIEND) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only // one PERSONAL thread per a pair of users and we can safely call it // repeatedly. const threadIDPerUser = await createPersonalThreads(viewer, request); const { userRelationshipOperations, errors: friendRequestErrors, } = await fetchFriendRequestRelationshipOperations(viewer, userIDs); errors = { ...errors, ...friendRequestErrors }; const undirectedInsertRows = []; const directedInsertRows = []; const directedDeleteIDs = []; const messageDatas = []; const now = Date.now(); for (const userID in userRelationshipOperations) { const operations = userRelationshipOperations[userID]; const ids = sortIDs(viewer.userID, userID); if (operations.length) { updateIDs.push(userID); } for (const operation of operations) { if (operation === 'delete_directed') { directedDeleteIDs.push(userID); } else if (operation === 'friend') { const [user1, user2] = ids; const status = undirectedStatus.FRIEND; undirectedInsertRows.push({ user1, user2, status }); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_accepted', }); } else if (operation === 'pending_friend') { const status = directedStatus.PENDING_FRIEND; directedInsertRows.push([viewer.userID, userID, status]); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_sent', }); } else if (operation === 'know_of') { const [user1, user2] = ids; const status = undirectedStatus.KNOW_OF; undirectedInsertRows.push({ user1, user2, status }); } else { invariant(false, `unexpected relationship operation ${operation}`); } } } const promises = [updateUndirectedRelationships(undirectedInsertRows)]; if (directedInsertRows.length) { const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedInsertRows} ON DUPLICATE KEY UPDATE status = VALUES(status) `; promises.push(dbQuery(directedInsertQuery)); } if (directedDeleteIDs.length) { const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE (user1 = ${viewer.userID} AND user2 IN (${directedDeleteIDs})) OR (status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${directedDeleteIDs}) AND user2 = ${viewer.userID}) `; promises.push(dbQuery(directedDeleteQuery)); } if (messageDatas.length > 0) { promises.push(createMessages(viewer, messageDatas, 'broadcast')); } await Promise.all(promises); } else if (action === relationshipActions.UNFRIEND) { updateIDs.push(...userIDs); const updateRows = userIDs.map((userID) => { const [user1, user2] = sortIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const deleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND (user1 = ${viewer.userID} AND user2 IN (${userIDs}) OR user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; await Promise.all([ updateUndirectedRelationships(updateRows, false), dbQuery(deleteQuery), ]); } else if (action === relationshipActions.BLOCK) { updateIDs.push(...userIDs); const directedRows = []; const undirectedRows = []; for (const userID of userIDs) { directedRows.push([viewer.userID, userID, directedStatus.BLOCKED]); const [user1, user2] = sortIDs(viewer.userID, userID); undirectedRows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); } const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedRows} ON DUPLICATE KEY UPDATE status = VALUES(status) `; const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${userIDs}) AND user2 = ${viewer.userID} `; await Promise.all([ dbQuery(directedInsertQuery), dbQuery(directedDeleteQuery), updateUndirectedRelationships(undirectedRows, false), ]); } else if (action === relationshipActions.UNBLOCK) { updateIDs.push(...userIDs); const query = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.BLOCKED} AND user1 = ${viewer.userID} AND user2 IN (${userIDs}) `; await dbQuery(query); } else { invariant(false, `action ${action} is invalid or not supported currently`); } await createUpdates( updateDatasForUserPairs(cartesianProduct([viewer.userID], updateIDs)), ); return Object.freeze({ ...errors }); } function updateDatasForUserPairs( userPairs: $ReadOnlyArray<[string, string]>, ): UpdateData[] { const time = Date.now(); const updateDatas = []; for (const [user1, user2] of userPairs) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user1, time, updatedUserID: user2, }); updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user2, time, updatedUserID: user1, }); } return updateDatas; } async function updateUndirectedRelationships( changeset: UndirectedRelationshipRow[], greatest: boolean = true, ) { if (!changeset.length) { return; } const rows = changeset.map((row) => [row.user1, row.user2, row.status]); const query = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${rows} `; if (greatest) { query.append( SQL`ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUES(status))`, ); } else { query.append(SQL`ON DUPLICATE KEY UPDATE status = VALUES(status)`); } await dbQuery(query); } async function createPersonalThreads( viewer: Viewer, request: RelationshipRequest, ) { invariant( request.action === relationshipActions.FRIEND, 'We should only create a PERSONAL threads when sending a FRIEND request, ' + `but we tried to do that for ${request.action}`, ); const threadIDPerUser = {}; const personalThreadsQuery = SQL` SELECT t.id AS threadID, m2.user AS user2 FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user IN (${request.userIDs}) WHERE t.type = ${threadTypes.PERSONAL} AND m1.role != -1 AND m2.role != -1 `; const [personalThreadsResult] = await dbQuery(personalThreadsQuery); for (const row of personalThreadsResult) { const user2 = row.user2.toString(); threadIDPerUser[user2] = row.threadID.toString(); } const threadCreationPromises = {}; for (const userID of request.userIDs) { if (threadIDPerUser[userID]) { continue; } threadCreationPromises[userID] = createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [userID], }, { forceAddMembers: true, updatesForCurrentSession: 'broadcast' }, ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); for (const userID in personalThreadPerUser) { const newThread = personalThreadPerUser[userID]; threadIDPerUser[userID] = newThread.newThreadID ?? newThread.newThreadInfo.id; } return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, }; diff --git a/server/src/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index bca36a8e4..bfdc35778 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,723 +1,723 @@ // @flow import invariant from 'invariant'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, threadMemberHasPermission, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; import { messageTypes, defaultNumberPerThread } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { firstLine } from 'lib/utils/string-utils'; import createMessages from '../creators/message-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, } from '../fetchers/thread-permission-fetchers'; import { verifyUserIDs, verifyUserOrCookieIDs, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { updateRoles } from './role-updaters'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getParentThreadRelationshipRowsForNewUsers, } from './thread-permission-updaters'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; - for (let row of result) { + for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; - for (let row of result) { + for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo) || !hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; - for (let member of threadInfo.members) { + for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{| +forceAddMembers: boolean, +forceUpdateRoot: boolean, |}>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !viewer.isScriptViewer && (threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE) ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...request.changes.newMemberIDs] : null; if (Object.keys(sqlUpdate).length === 0 && !newMemberIDs) { throw new ServerError('invalid_parameters'); } if (newMemberIDs) { validationPromises.fetchNewMembers = fetchKnownUserInfos( viewer, newMemberIDs, ); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); const checks = []; if ( sqlUpdate.name !== undefined || sqlUpdate.description !== undefined || sqlUpdate.color !== undefined ) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } validationPromises.hasNecessaryPermissions = checkThread( viewer, request.threadID, checks, ); const { fetchNewMembers, serverThreadInfos, hasNecessaryPermissions, } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const threadRootChanged = forceUpdateRoot || nextParentThreadID !== oldParentThreadID || nextThreadType !== oldThreadType; if (threadRootChanged && nextParentThreadID) { const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } } if (fetchNewMembers) { invariant(newMemberIDs, 'should be set'); const newIDs = newMemberIDs; // for Flow for (const newMemberID of newIDs) { if (!fetchNewMembers[newMemberID]) { if (!forceAddMembers) { throw new ServerError('invalid_credentials'); } else if (nextThreadType === threadTypes.SIDEBAR) { throw new ServerError('invalid_thread_type'); } else { continue; } } const { relationshipStatus } = fetchNewMembers[newMemberID]; if ( relationshipStatus === userRelationshipStatus.FRIEND && nextThreadType !== threadTypes.SIDEBAR ) { continue; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { throw new ServerError('invalid_credentials'); } else if ( threadMemberHasPermission( serverThreadInfo, newMemberID, threadPermissions.KNOW_OF, ) ) { continue; } else if (forceAddMembers && nextThreadType !== threadTypes.SIDEBAR) { continue; } throw new ServerError('invalid_credentials'); } } const intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = changeRole( request.threadID, newMemberIDs, null, ); } if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { if (forceUpdateRoot || nextThreadType !== oldThreadType) { await updateRoles(viewer, request.threadID, nextThreadType); } return await recalculateAllPermissions(request.threadID, nextThreadType); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipRows = []; if (recalculatePermissionsChangeset && newMemberIDs) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( request.threadID, recalculateMembershipRows, newMemberIDs, ); relationshipRows.push( ...recalculateRelationshipRows, ...parentRelationshipRows, ); } else if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipRows.push(...recalculateRelationshipRows); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipRows: addMembersRelationshipRows, } = addMembersChangeset; relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); } const changeset = { membershipRows, relationshipRows }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); const time = Date.now(); const messageDatas = []; - for (let fieldName in changedFields) { + for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const newMessageInfos = await createMessages(viewer, messageDatas); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (isMember || !hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); if (!changeset) { throw new ServerError('unknown_error'); } setJoinsToUnread(changeset.membershipRows, viewer.userID, request.threadID); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; await createMessages(viewer, [messageData]); const threadSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (!hasMinCodeVersion(viewer.platformDetails, 62)) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, }; diff --git a/server/src/utils/json-stream.js b/server/src/utils/json-stream.js index d75bcc341..ac5194ba3 100644 --- a/server/src/utils/json-stream.js +++ b/server/src/utils/json-stream.js @@ -1,50 +1,50 @@ // @flow import JSONStream from 'JSONStream'; import replaceStream from 'replacestream'; import Combine from 'stream-combiner'; type Promisable = Promise | T; function streamJSON }>( res: $Response, input: T, ): stream$Readable { const jsonStream = Combine( JSONStream.stringifyObject('{', ',', '}'), replaceStream(/ }>( stream: JSONStream, input: T, ) { const blocking = []; - for (let key in input) { + for (const key in input) { const value = input[key]; if (value instanceof Promise) { blocking.push( (async () => { const result = await value; stream.write([key, result]); })(), ); } else { stream.write([key, value]); } } Promise.all(blocking).then(() => { stream.end(); }); } function waitForStream(readable: stream$Readable): Promise { return new Promise((r) => { readable.on('end', r); }); } export { streamJSON, waitForStream }; diff --git a/server/src/utils/validation-utils.js b/server/src/utils/validation-utils.js index 3fa5989be..f281ac185 100644 --- a/server/src/utils/validation-utils.js +++ b/server/src/utils/validation-utils.js @@ -1,207 +1,207 @@ // @flow import t from 'tcomb'; import { ServerError } from 'lib/utils/errors'; import { verifyClientSupported } from '../session/version'; import type { Viewer } from '../session/viewer'; function tBool(value: boolean) { return t.irreducible('literal bool', (x) => x === value); } function tString(value: string) { return t.irreducible('literal string', (x) => x === value); } function tShape(spec: { [key: string]: * }) { return t.interface(spec, { strict: true }); } function tRegex(regex: RegExp) { return t.refinement(t.String, (val) => regex.test(val)); } function tNumEnum(nums: $ReadOnlyArray) { return t.refinement(t.Number, (input: number) => { for (const num of nums) { if (input === num) { return true; } } return false; }); } const tDate = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/); const tColor = tRegex(/^[a-fA-F0-9]{6}$/); // we don't include # char const tPlatform = t.enums.of(['ios', 'android', 'web']); const tDeviceType = t.enums.of(['ios', 'android']); const tPlatformDetails = tShape({ platform: tPlatform, codeVersion: t.maybe(t.Number), stateVersion: t.maybe(t.Number), }); const tPassword = t.refinement(t.String, (password: string) => password); const tCookie = tRegex(/^(user|anonymous)=[0-9]+:[0-9a-f]+$/); async function validateInput(viewer: Viewer, inputValidator: *, input: *) { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } checkInputValidator(inputValidator, input); } function checkInputValidator(inputValidator: *, input: *) { if (!inputValidator || inputValidator.is(input)) { return; } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } async function checkClientSupported( viewer: Viewer, inputValidator: *, input: *, ) { let platformDetails; if (inputValidator) { platformDetails = findFirstInputMatchingValidator( inputValidator, tPlatformDetails, input, ); } if (!platformDetails && inputValidator) { const platform = findFirstInputMatchingValidator( inputValidator, tPlatform, input, ); if (platform) { platformDetails = { platform }; } } if (!platformDetails) { ({ platformDetails } = viewer); } await verifyClientSupported(viewer, platformDetails); } const redactedString = '********'; const redactedTypes = [tPassword, tCookie]; function sanitizeInput(inputValidator: *, input: *) { if (!inputValidator) { return input; } if (redactedTypes.includes(inputValidator) && typeof input === 'string') { return redactedString; } if ( inputValidator.meta.kind === 'maybe' && redactedTypes.includes(inputValidator.meta.type) && typeof input === 'string' ) { return redactedString; } if ( inputValidator.meta.kind !== 'interface' || typeof input !== 'object' || !input ) { return input; } const result = {}; - for (let key in input) { + for (const key in input) { const value = input[key]; const validator = inputValidator.meta.props[key]; result[key] = sanitizeInput(validator, value); } return result; } function findFirstInputMatchingValidator( wholeInputValidator: *, inputValidatorToMatch: *, input: *, ): any { if (!wholeInputValidator || input === null || input === undefined) { return null; } if ( wholeInputValidator === inputValidatorToMatch && wholeInputValidator.is(input) ) { return input; } if (wholeInputValidator.meta.kind === 'maybe') { return findFirstInputMatchingValidator( wholeInputValidator.meta.type, inputValidatorToMatch, input, ); } if ( wholeInputValidator.meta.kind === 'interface' && typeof input === 'object' ) { - for (let key in input) { + for (const key in input) { const value = input[key]; const validator = wholeInputValidator.meta.props[key]; const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } if (wholeInputValidator.meta.kind === 'union') { - for (let validator of wholeInputValidator.meta.types) { + for (const validator of wholeInputValidator.meta.types) { if (validator.is(input)) { return findFirstInputMatchingValidator( validator, inputValidatorToMatch, input, ); } } } if (wholeInputValidator.meta.kind === 'list' && Array.isArray(input)) { const validator = wholeInputValidator.meta.type; - for (let value of input) { + for (const value of input) { const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } return null; } export { tBool, tString, tShape, tRegex, tNumEnum, tDate, tColor, tPlatform, tDeviceType, tPlatformDetails, tPassword, tCookie, validateInput, checkInputValidator, checkClientSupported, }; diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js index 2ceac1417..82416f592 100644 --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -1,93 +1,93 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import Multimedia from '../media/multimedia.react'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react'; import type { MessagePositionInfo, OnMessagePositionInfo, } from './message-position-types'; import sendFailed from './multimedia-message-send-failed'; type BaseProps = {| +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionInfo, +setModal: (modal: ?React.Node) => void, |}; type Props = {| ...BaseProps, // withInputState +inputState: ?InputState, |}; class MultimediaMessage extends React.PureComponent { render() { const { item, setModal, inputState } = this.props; invariant( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA, 'MultimediaMessage should only be used for multimedia messages', ); const { localID, media } = item.messageInfo; invariant(inputState, 'inputState should be set in MultimediaMessage'); const pendingUploads = localID ? inputState.assignedUploads[localID] : null; const multimedia = []; - for (let singleMedia of media) { + for (const singleMedia of media) { const pendingUpload = pendingUploads ? pendingUploads.find((upload) => upload.localID === singleMedia.id) : null; multimedia.push( , ); } invariant(multimedia.length > 0, 'should be at least one multimedia...'); const content = multimedia.length > 1 ? (
{multimedia}
) : ( multimedia ); return ( 1} borderRadius={16} > {content} ); } } export default React.memo(function ConnectedMultimediaMessage( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); return ; }); diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index c684ad4c9..c9492ba11 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,204 +1,204 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import css from './chat-message-list.css'; import { InlineSidebar } from './inline-sidebar.react'; import type { MessagePositionInfo, OnMessagePositionInfo, } from './message-position-types'; import SidebarTooltip from './sidebar-tooltip.react'; type BaseProps = {| +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionInfo, |}; type Props = {| ...BaseProps, // Redux state +sidebarExistsOrCanBeCreated: boolean, |}; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } const { item, threadInfo, sidebarExistsOrCanBeCreated } = this.props; const { id } = item.messageInfo; let sidebarTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && sidebarExistsOrCanBeCreated ) { sidebarTooltip = ( ); } return (
{this.linkedRobotext()}
{sidebarTooltip}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; - for (let splitPart of robotextParts) { + for (const splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } type BaseInnerThreadEntityProps = {| +id: string, +name: string, |}; type InnerThreadEntityProps = {| ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, |}; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector((state) => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: {| color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } export default React.memo(function ConnectedRobotextMessage( props: BaseProps, ) { const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( props.threadInfo, props.item, ); return ( ); }); diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js index eb5b526e0..0dd31d800 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1059 +1,1061 @@ // @flow import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy'; import _keyBy from 'lodash/fp/keyBy'; import _omit from 'lodash/fp/omit'; import _partition from 'lodash/fp/partition'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import type { UploadMultimediaResult, MediaMissionStep, MediaMissionFailure, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types'; import type { RawImagesMessageInfo } from 'lib/types/messages/images'; import type { RawMediaMessageInfo } from 'lib/types/messages/media'; import type { RawTextMessageInfo } from 'lib/types/messages/text'; import type { Dispatch } from 'lib/types/redux-types'; import { reportTypes } from 'lib/types/report-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import { validateFile, preloadImage } from '../media/media-utils'; import InvalidUploadModal from '../modals/chat/invalid-upload.react'; import { useSelector } from '../redux/redux-utils'; import { type PendingMultimediaUpload, InputStateContext } from './input-state'; let nextLocalUploadID = 0; type BaseProps = {| +children: React.Node, +setModal: (modal: ?React.Node) => void, |}; type Props = {| ...BaseProps, +activeChatThreadID: ?string, +viewerID: ?string, +messageStoreMessages: { [id: string]: RawMessageInfo }, +exifRotate: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +deleteUpload: (id: string) => Promise, +sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, +sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| +pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, +drafts: { [threadID: string]: string }, |}; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, drafts: {}, }; replyCallbacks: Array<(message: string) => void> = []; static completedMessageIDs(state: State) { const completed = new Map(); - for (let threadID in state.pendingUploads) { + for (const threadID in state.pendingUploads) { const pendingUploads = state.pendingUploads[threadID]; - for (let localUploadID in pendingUploads) { + for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID, serverID, failed } = upload; if (!messageID || !messageID.startsWith('local')) { continue; } if (!serverID || failed) { completed.set(messageID, false); continue; } if (completed.get(messageID) === undefined) { completed.set(messageID, true); } } } const messageIDs = new Set(); - for (let [messageID, isCompleted] of completed) { + for (const [messageID, isCompleted] of completed) { if (isCompleted) { messageIDs.add(messageID); } } return messageIDs; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const previouslyAssignedMessageIDs = new Set(); - for (let threadID in prevState.pendingUploads) { + for (const threadID in prevState.pendingUploads) { const pendingUploads = prevState.pendingUploads[threadID]; - for (let localUploadID in pendingUploads) { + for (const localUploadID in pendingUploads) { const { messageID } = pendingUploads[localUploadID]; if (messageID) { previouslyAssignedMessageIDs.add(messageID); } } } const newlyAssignedUploads = new Map(); - for (let threadID in this.state.pendingUploads) { + for (const threadID in this.state.pendingUploads) { const pendingUploads = this.state.pendingUploads[threadID]; - for (let localUploadID in pendingUploads) { + for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID } = upload; if ( !messageID || !messageID.startsWith('local') || previouslyAssignedMessageIDs.has(messageID) ) { continue; } let assignedUploads = newlyAssignedUploads.get(messageID); if (!assignedUploads) { assignedUploads = { threadID, uploads: [] }; newlyAssignedUploads.set(messageID, assignedUploads); } assignedUploads.uploads.push(upload); } } const newMessageInfos = new Map(); - for (let [messageID, assignedUploads] of newlyAssignedUploads) { + for (const [messageID, assignedUploads] of newlyAssignedUploads) { const { uploads, threadID } = assignedUploads; const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = uploads.map( ({ localID, serverID, uri, mediaType, dimensions, loop }) => { // We can get into this state where dimensions are null if the user is // uploading a file type that the browser can't render. In that case // we fake the dimensions here while we wait for the server to tell us // the true dimensions. We actually don't use the dimensions on the // web side currently, but if we ever change that (for instance if we // want to render a properly sized loading overlay like we do on // native), 0,0 is probably a good default. const shimmedDimensions = dimensions ? dimensions : { height: 0, width: 0 }; if (mediaType === 'photo') { return { id: serverID ? serverID : localID, uri, type: 'photo', dimensions: shimmedDimensions, }; } else { return { id: serverID ? serverID : localID, uri, type: 'video', dimensions: shimmedDimensions, loop, }; } }, ); const messageInfo = createMediaMessageInfo({ localID: messageID, threadID, creatorID, media, }); newMessageInfos.set(messageID, messageInfo); } const currentlyCompleted = InputStateContainer.completedMessageIDs( this.state, ); const previouslyCompleted = InputStateContainer.completedMessageIDs( prevState, ); - for (let messageID of currentlyCompleted) { + for (const messageID of currentlyCompleted) { if (previouslyCompleted.has(messageID)) { continue; } let rawMessageInfo = newMessageInfos.get(messageID); if (rawMessageInfo) { newMessageInfos.delete(messageID); } else { rawMessageInfo = this.getRawMultimediaMessageInfo(messageID); } this.sendMultimediaMessage(rawMessageInfo); } - for (let [, messageInfo] of newMessageInfos) { + for (const [, messageInfo] of newMessageInfos) { this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); } } getRawMultimediaMessageInfo( localMessageID: string, ): RawMultimediaMessageInfo { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); return rawMessageInfo; } sendMultimediaMessage(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; - for (let { id } of messageInfo.media) { + for (const { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); this.setState((prevState) => { const prevUploads = prevState.pendingUploads[threadID]; const newUploads = {}; - for (let localUploadID in prevUploads) { + for (const localUploadID in prevUploads) { const upload = prevUploads[localUploadID]; if (upload.messageID !== localID) { newUploads[localUploadID] = upload; } else if (!upload.uriIsReal) { newUploads[localUploadID] = { ...upload, messageID: result.id, }; } } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newUploads, }, }; }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = _memoize((threadID: string) => createSelector( (state: State) => state.pendingUploads[threadID], (state: State) => state.drafts[threadID], ( pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload }, draft: ?string, ) => { let threadPendingUploads = []; const assignedUploads = {}; if (pendingUploads) { const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] = _partition( 'messageID', )(pendingUploads); threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs); const threadAssignedUploads = _groupBy('messageID')( uploadsWithMessageIDs, ); - for (let messageID in threadAssignedUploads) { + for (const messageID in threadAssignedUploads) { // lodash libdefs don't return $ReadOnlyArray assignedUploads[messageID] = [...threadAssignedUploads[messageID]]; } } return { pendingUploads: threadPendingUploads, assignedUploads, draft: draft ? draft : '', appendFiles: (files: $ReadOnlyArray) => this.appendFiles(threadID, files), cancelPendingUpload: (localUploadID: string) => this.cancelPendingUpload(threadID, localUploadID), sendTextMessage: (messageInfo: RawTextMessageInfo) => this.sendTextMessage(messageInfo), createMultimediaMessage: (localID: number) => this.createMultimediaMessage(threadID, localID), setDraft: (newDraft: string) => this.setDraft(threadID, newDraft), messageHasUploadFailure: (localMessageID: string) => this.messageHasUploadFailure(assignedUploads[localMessageID]), retryMultimediaMessage: (localMessageID: string) => this.retryMultimediaMessage( threadID, localMessageID, assignedUploads[localMessageID], ), addReply: (message: string) => this.addReply(message), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, }; }, ), ); async appendFiles( threadID: string, files: $ReadOnlyArray, ): Promise { const selectionTime = Date.now(); const { setModal } = this.props; const appendResults = await Promise.all( files.map((file) => this.appendFile(file, selectionTime)), ); if (appendResults.some(({ result }) => !result.success)) { setModal(); const time = Date.now() - selectionTime; const reports = []; - for (let { steps, result } of appendResults) { + for (const appendResult of appendResults) { + const { steps } = appendResult; + let { result } = appendResult; let uploadLocalID; if (result.success) { uploadLocalID = result.pendingUpload.localID; result = { success: false, reason: 'web_sibling_validation_failed' }; } const mediaMission = { steps, result, userTime: time, totalTime: time }; reports.push({ mediaMission, uploadLocalID }); } this.queueMediaMissionReports(reports); return false; } const newUploads = appendResults.map(({ result }) => { invariant(result.success, 'any failed validation should be caught above'); return result.pendingUpload; }); const newUploadsObject = _keyBy('localID')(newUploads); this.setState( (prevState) => { const prevUploads = prevState.pendingUploads[threadID]; const mergedUploads = prevUploads ? { ...prevUploads, ...newUploadsObject } : newUploadsObject; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: mergedUploads, }, }; }, () => this.uploadFiles(threadID, newUploads), ); return true; } async appendFile( file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | {| success: true, pendingUpload: PendingMultimediaUpload |}, }> { const steps = [ { step: 'web_selection', filename: file.name, size: file.size, mime: file.type, selectTime, }, ]; let response; const validationStart = Date.now(); try { response = await validateFile(file, this.props.exifRotate); } catch (e) { return { steps, result: { success: false, reason: 'processing_exception', time: Date.now() - validationStart, exceptionMessage: getMessageForException(e), }, }; } const { steps: validationSteps, result } = response; steps.push(...validationSteps); if (!result.success) { return { steps, result }; } const { uri, file: fixedFile, mediaType, dimensions } = result; return { steps, result: { success: true, pendingUpload: { localID: `localUpload${nextLocalUploadID++}`, serverID: null, messageID: null, failed: null, file: fixedFile, mediaType, dimensions, uri, loop: false, uriIsReal: false, progressPercent: 0, abort: null, steps, selectTime, }, }, }; } uploadFiles( threadID: string, uploads: $ReadOnlyArray, ) { return Promise.all( uploads.map((upload) => this.uploadFile(threadID, upload)), ); } async uploadFile(threadID: string, upload: PendingMultimediaUpload) { const { selectTime, localID } = upload; const steps = [...upload.steps]; let userTime; const sendReport = (missionResult: MediaMissionResult) => { const latestUpload = this.state.pendingUploads[threadID][localID]; invariant( latestUpload, `pendingUpload ${localID} for ${threadID} missing in sendReport`, ); const { serverID, messageID } = latestUpload; const totalTime = Date.now() - selectTime; userTime = userTime ? userTime : totalTime; const mission = { steps, result: missionResult, totalTime, userTime }; this.queueMediaMissionReports([ { mediaMission: mission, uploadLocalID: localID, uploadServerID: serverID, messageLocalID: messageID, }, ]); }; let uploadResult, uploadExceptionMessage; const uploadStart = Date.now(); try { uploadResult = await this.props.uploadMultimedia( upload.file, { ...upload.dimensions, loop: false }, { onProgress: (percent: number) => this.setProgress(threadID, localID, percent), abortHandler: (abort: () => void) => this.handleAbortCallback(threadID, localID, abort), }, ); } catch (e) { uploadExceptionMessage = getMessageForException(e); this.handleUploadFailure(threadID, localID, e); } userTime = Date.now() - selectTime; steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: upload.file.name, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, }); if (!uploadResult) { sendReport({ success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }); return; } const result = uploadResult; const uploadAfterSuccess = this.state.pendingUploads[threadID][localID]; invariant( uploadAfterSuccess, `pendingUpload ${localID}/${result.id} for ${threadID} missing ` + `after upload`, ); if (uploadAfterSuccess.messageID) { this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterSuccess.messageID, currentMediaID: localID, mediaUpdate: { id: result.id, }, }, }); } this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${threadID} ` + `missing while assigning serverID`, ); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localID]: { ...currentUpload, serverID: result.id, abort: null, }, }, }, }; }); const { steps: preloadSteps } = await preloadImage(result.uri); steps.push(...preloadSteps); sendReport({ success: true }); const uploadAfterPreload = this.state.pendingUploads[threadID][localID]; invariant( uploadAfterPreload, `pendingUpload ${localID}/${result.id} for ${threadID} missing ` + `after preload`, ); if (uploadAfterPreload.messageID) { const { mediaType, uri, dimensions, loop } = result; this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterPreload.messageID, currentMediaID: uploadAfterPreload.serverID ? uploadAfterPreload.serverID : uploadAfterPreload.localID, mediaUpdate: { type: mediaType, uri, dimensions, loop }, }, }); } this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${threadID} ` + `missing while assigning URI`, ); const { messageID } = currentUpload; if (messageID && !messageID.startsWith('local')) { const newPendingUploads = _omit([localID])(uploads); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localID]: { ...currentUpload, uri: result.uri, mediaType: result.mediaType, dimensions: result.dimensions, uriIsReal: true, loop: result.loop, }, }, }, }; }); } handleAbortCallback( threadID: string, localUploadID: string, abort: () => void, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been cancelled before we were even handed the // abort function. We should immediately abort. abort(); } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localUploadID]: { ...upload, abort, }, }, }, }; }); } handleUploadFailure(threadID: string, localUploadID: string, e: any) { this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const upload = uploads[localUploadID]; if (!upload || !upload.abort || upload.serverID) { // The upload has been cancelled or completed before it failed return {}; } const failed = e instanceof Error && e.message ? e.message : 'failed'; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localUploadID]: { ...upload, failed, progressPercent: 0, abort: null, }, }, }, }; }); } queueMediaMissionReports( partials: $ReadOnlyArray<{| mediaMission: MediaMission, uploadLocalID?: ?string, uploadServerID?: ?string, messageLocalID?: ?string, |}>, ) { const reports = partials.map( ({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({ type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID, uploadLocalID, messageLocalID, }), ); this.props.dispatch({ type: queueReportsActionType, payload: { reports } }); } cancelPendingUpload(threadID: string, localUploadID: string) { let revokeURL, abortRequest; this.setState( (prevState) => { const currentPendingUploads = prevState.pendingUploads[threadID]; if (!currentPendingUploads) { return {}; } const pendingUpload = currentPendingUploads[localUploadID]; if (!pendingUpload) { return {}; } if (!pendingUpload.uriIsReal) { revokeURL = pendingUpload.uri; } if (pendingUpload.abort) { abortRequest = pendingUpload.abort; } if (pendingUpload.serverID) { this.props.deleteUpload(pendingUpload.serverID); } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); } sendTextMessage(messageInfo: RawTextMessageInfo) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); } async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } // Creates a MultimediaMessage from the unassigned pending uploads, // if there are any createMultimediaMessage(threadID: string, localID: number) { const localMessageID = `local${localID}`; this.setState((prevState) => { const currentPendingUploads = prevState.pendingUploads[threadID]; if (!currentPendingUploads) { return {}; } const newPendingUploads = {}; let uploadAssigned = false; - for (let localUploadID in currentPendingUploads) { + for (const localUploadID in currentPendingUploads) { const upload = currentPendingUploads[localUploadID]; if (upload.messageID) { newPendingUploads[localUploadID] = upload; } else { const newUpload = { ...upload, messageID: localMessageID, }; uploadAssigned = true; newPendingUploads[localUploadID] = newUpload; } } if (!uploadAssigned) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); } setDraft(threadID: string, draft: string) { this.setState((prevState) => ({ drafts: { ...prevState.drafts, [threadID]: draft, }, })); } setProgress( threadID: string, localUploadID: string, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[threadID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); } messageHasUploadFailure( pendingUploads: ?$ReadOnlyArray, ) { if (!pendingUploads) { return false; } return pendingUploads.some((upload) => upload.failed); } retryMultimediaMessage( threadID: string, localMessageID: string, pendingUploads: ?$ReadOnlyArray, ) { const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawMediaMessageInfo); } else { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawImagesMessageInfo); } const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { this.sendMultimediaMessage(newRawMessageInfo); return; } if (!pendingUploads) { return; } // We're not actually starting the send here, // we just use this action to update the message's timestamp in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); const uploadIDsToRetry = new Set(); const uploadsToRetry = []; - for (let pendingUpload of pendingUploads) { + for (const pendingUpload of pendingUploads) { const { serverID, messageID, localID, abort } = pendingUpload; if (serverID || messageID !== localMessageID) { continue; } if (abort) { abort(); } uploadIDsToRetry.add(localID); uploadsToRetry.push(pendingUpload); } this.setState((prevState) => { const prevPendingUploads = prevState.pendingUploads[threadID]; if (!prevPendingUploads) { return {}; } const newPendingUploads = {}; let pendingUploadChanged = false; - for (let localID in prevPendingUploads) { + for (const localID in prevPendingUploads) { const pendingUpload = prevPendingUploads[localID]; if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) { newPendingUploads[localID] = { ...pendingUpload, failed: null, progressPercent: 0, abort: null, }; pendingUploadChanged = true; } else { newPendingUploads[localID] = pendingUpload; } } if (!pendingUploadChanged) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); this.uploadFiles(threadID, uploadsToRetry); } addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; render() { const { activeChatThreadID } = this.props; const inputState = activeChatThreadID ? this.inputStateSelector(activeChatThreadID)(this.state) : null; return ( {this.props.children} ); } } export default React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const exifRotate = useSelector((state) => { const browser = detectBrowser(state.userAgent); return !browser || (browser.name !== 'safari' && browser.name !== 'chrome'); }); const activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const messageStoreMessages = useSelector( (state) => state.messageStore.messages, ); const callUploadMultimedia = useServerCall(uploadMultimedia); const callDeleteUpload = useServerCall(deleteUpload); const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); const callSendTextMessage = useServerCall(sendTextMessage); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); return ( ); });